diff --git a/agent/humitifier/config.py b/agent/humitifier/config.py index 0193ad9..383abfe 100644 --- a/agent/humitifier/config.py +++ b/agent/humitifier/config.py @@ -15,7 +15,10 @@ class Config: inventory: list[str] pssh: dict tasks: dict[str, str] + token_endpoint: str upload_endpoint: str + client_id: str + client_secret: str @classmethod def load(cls) -> "Config": diff --git a/agent/humitifier/tasks.py b/agent/humitifier/tasks.py index d2213dd..ffe864c 100644 --- a/agent/humitifier/tasks.py +++ b/agent/humitifier/tasks.py @@ -1,3 +1,5 @@ +import base64 + import asyncio from sys import stdout @@ -106,6 +108,33 @@ async def scan_hosts(): ) await conn.close() +async def request_oauth_token(): + credential = "{0}:{1}".format(CONFIG.client_id, CONFIG.client_secret) + encoded_credentials = base64.b64encode(credential.encode("utf-8")).decode("utf-8") + + headers = { + "Authorization": f"Basic {encoded_credentials}", + "Cache-Control": "no-cache", + "Content-Type": "application/x-www-form-urlencoded" + } + + data= { + "grant_type": "client_credentials", + "scope": "system" + } + + response = await asyncio.to_thread( + requests.post, + CONFIG.token_endpoint, + headers=headers, + data=data, + ) + print(response.json()) + json_data = response.json() + if "error" in json_data: + return None + return json_data["access_token"] + @app.task(after_success(scan_hosts)) async def parse_facts(): @@ -129,12 +158,20 @@ async def parse_facts(): } ) - await asyncio.to_thread( - requests.post, - CONFIG.upload_endpoint, - data=json.dumps(host_data), - headers={'Content-Type': 'application/json'} - ) + token = await request_oauth_token() + + if token: + await asyncio.to_thread( + requests.post, + CONFIG.upload_endpoint, + data=json.dumps(host_data), + headers={ + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + ) + else: + logger.error("Failed to get OAuth token") await conn.executemany( """INSERT INTO facts(name, host, scan, data) VALUES($1, $2, $3, $4)""", diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 25ef6ba..0d926e7 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "humitifier" -version = "3.0.0" +version = "3.2.0" description = "Tools and interfaces for displaying server resources" authors = ["Donatas Rasiukevicius "] diff --git a/humitifier-server/docker/entrypoint.sh b/humitifier-server/docker/entrypoint.sh index 2d263bc..49c4daa 100644 --- a/humitifier-server/docker/entrypoint.sh +++ b/humitifier-server/docker/entrypoint.sh @@ -7,4 +7,4 @@ source /app/.venv/bin/activate python src/manage.py migrate # Run da server -gunicorn humitifier_server.wsgi:application -c gunicorn.conf.py "$@" \ No newline at end of file +exec gunicorn humitifier_server.wsgi:application -c gunicorn.conf.py "$@" \ No newline at end of file diff --git a/humitifier-server/poetry.lock b/humitifier-server/poetry.lock index 13d0d4c..53b7102 100644 --- a/humitifier-server/poetry.lock +++ b/humitifier-server/poetry.lock @@ -25,6 +25,25 @@ files = [ [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + [[package]] name = "black" version = "24.10.0" @@ -121,6 +140,85 @@ files = [ {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "charset-normalizer" version = "3.4.0" @@ -260,6 +358,55 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "43.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "curtsies" version = "0.4.2" @@ -383,6 +530,23 @@ files = [ [package.dependencies] Django = ">=4.2" +[[package]] +name = "django-oauth-toolkit" +version = "3.0.1" +description = "OAuth2 Provider for Django" +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_oauth_toolkit-3.0.1-py3-none-any.whl", hash = "sha256:3ef00b062a284f2031b0732b32dc899e3bbf0eac221bbb1cffcb50b8932e55ed"}, + {file = "django_oauth_toolkit-3.0.1.tar.gz", hash = "sha256:7200e4a9fb229b145a6d808cbf0423b6d69a87f68557437733eec3c0cf71db02"}, +] + +[package.dependencies] +django = ">=4.2" +jwcrypto = ">=1.5.0" +oauthlib = ">=3.2.2" +requests = ">=2.13.0" + [[package]] name = "django-simple-menu" version = "2.1.3" @@ -411,6 +575,29 @@ files = [ [package.dependencies] django = ">=4.2" +[[package]] +name = "drf-spectacular" +version = "0.27.2" +description = "Sane and flexible OpenAPI 3 schema generation for Django REST framework" +optional = false +python-versions = ">=3.7" +files = [ + {file = "drf-spectacular-0.27.2.tar.gz", hash = "sha256:a199492f2163c4101055075ebdbb037d59c6e0030692fc83a1a8c0fc65929981"}, + {file = "drf_spectacular-0.27.2-py3-none-any.whl", hash = "sha256:b1c04bf8b2fbbeaf6f59414b4ea448c8787aba4d32f76055c3b13335cf7ec37b"}, +] + +[package.dependencies] +Django = ">=2.2" +djangorestframework = ">=3.10.3" +inflection = ">=0.3.1" +jsonschema = ">=2.6.0" +PyYAML = ">=5.1" +uritemplate = ">=2.0.0" + +[package.extras] +offline = ["drf-spectacular-sidecar"] +sidecar = ["drf-spectacular-sidecar"] + [[package]] name = "faker" version = "30.8.1" @@ -547,6 +734,17 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" +optional = false +python-versions = ">=3.5" +files = [ + {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, + {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, +] + [[package]] name = "jinxed" version = "1.3.0" @@ -561,6 +759,74 @@ files = [ [package.dependencies] ansicon = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "josepy" +version = "1.14.0" +description = "JOSE protocol implementation in Python" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "josepy-1.14.0-py3-none-any.whl", hash = "sha256:d2b36a30f316269f3242f4c2e45e15890784178af5ec54fa3e49cf9234ee22e0"}, + {file = "josepy-1.14.0.tar.gz", hash = "sha256:308b3bf9ce825ad4d4bba76372cf19b5dc1c2ce96a9d298f9642975e64bd13dd"}, +] + +[package.dependencies] +cryptography = ">=1.5" +pyopenssl = ">=0.13" + +[package.extras] +docs = ["sphinx (>=4.3.0)", "sphinx-rtd-theme (>=1.0)"] + +[[package]] +name = "jsonschema" +version = "4.23.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, + {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2024.10.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +files = [ + {file = "jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf"}, + {file = "jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + +[[package]] +name = "jwcrypto" +version = "1.5.6" +description = "Implementation of JOSE Web standards" +optional = false +python-versions = ">= 3.8" +files = [ + {file = "jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789"}, + {file = "jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039"}, +] + +[package.dependencies] +cryptography = ">=3.4" +typing-extensions = ">=4.5.0" + [[package]] name = "markdown" version = "3.7" @@ -576,6 +842,23 @@ files = [ docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] testing = ["coverage", "pyyaml"] +[[package]] +name = "mozilla-django-oidc" +version = "4.0.1" +description = "A lightweight authentication and access management library for integration with OpenID Connect enabled authentication services." +optional = false +python-versions = "*" +files = [ + {file = "mozilla-django-oidc-4.0.1.tar.gz", hash = "sha256:4ff8c64069e3e05c539cecf9345e73225a99641a25e13b7a5f933ec897b58918"}, + {file = "mozilla_django_oidc-4.0.1-py2.py3-none-any.whl", hash = "sha256:04ef58759be69f22cdc402d082480aaebf193466cad385dc9e4f8df2a0b187ca"}, +] + +[package.dependencies] +cryptography = "*" +Django = ">=3.2" +josepy = "*" +requests = "*" + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -587,6 +870,22 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "oauthlib" +version = "3.2.2" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.6" +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + [[package]] name = "packaging" version = "24.1" @@ -719,6 +1018,17 @@ files = [ {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, ] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pygments" version = "2.18.0" @@ -733,6 +1043,24 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyopenssl" +version = "24.2.1" +description = "Python wrapper module around the OpenSSL library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyOpenSSL-24.2.1-py3-none-any.whl", hash = "sha256:967d5719b12b243588573f39b0c677637145c7a1ffedcd495a487e58177fbb8d"}, + {file = "pyopenssl-24.2.1.tar.gz", hash = "sha256:4247f0dbe3748d560dcbb2ff3ea01af0f9a1a001ef5f7c4c647956ed8cbf0e95"}, +] + +[package.dependencies] +cryptography = ">=41.0.5,<44" + +[package.extras] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx-rtd-theme"] +test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -758,6 +1086,83 @@ files = [ {file = "pyxdg-0.28.tar.gz", hash = "sha256:3267bb3074e934df202af2ee0868575484108581e6f3cb006af1da35395e88b4"}, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "referencing" +version = "0.35.1" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, + {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" + [[package]] name = "requests" version = "2.32.3" @@ -779,6 +1184,105 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rpds-py" +version = "0.21.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.9" +files = [ + {file = "rpds_py-0.21.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a017f813f24b9df929674d0332a374d40d7f0162b326562daae8066b502d0590"}, + {file = "rpds_py-0.21.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:20cc1ed0bcc86d8e1a7e968cce15be45178fd16e2ff656a243145e0b439bd250"}, + {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad116dda078d0bc4886cb7840e19811562acdc7a8e296ea6ec37e70326c1b41c"}, + {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:808f1ac7cf3b44f81c9475475ceb221f982ef548e44e024ad5f9e7060649540e"}, + {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de552f4a1916e520f2703ec474d2b4d3f86d41f353e7680b597512ffe7eac5d0"}, + {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:efec946f331349dfc4ae9d0e034c263ddde19414fe5128580f512619abed05f1"}, + {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b80b4690bbff51a034bfde9c9f6bf9357f0a8c61f548942b80f7b66356508bf5"}, + {file = "rpds_py-0.21.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085ed25baac88953d4283e5b5bd094b155075bb40d07c29c4f073e10623f9f2e"}, + {file = "rpds_py-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:daa8efac2a1273eed2354397a51216ae1e198ecbce9036fba4e7610b308b6153"}, + {file = "rpds_py-0.21.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:95a5bad1ac8a5c77b4e658671642e4af3707f095d2b78a1fdd08af0dfb647624"}, + {file = "rpds_py-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3e53861b29a13d5b70116ea4230b5f0f3547b2c222c5daa090eb7c9c82d7f664"}, + {file = "rpds_py-0.21.0-cp310-none-win32.whl", hash = "sha256:ea3a6ac4d74820c98fcc9da4a57847ad2cc36475a8bd9683f32ab6d47a2bd682"}, + {file = "rpds_py-0.21.0-cp310-none-win_amd64.whl", hash = "sha256:b8f107395f2f1d151181880b69a2869c69e87ec079c49c0016ab96860b6acbe5"}, + {file = "rpds_py-0.21.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5555db3e618a77034954b9dc547eae94166391a98eb867905ec8fcbce1308d95"}, + {file = "rpds_py-0.21.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97ef67d9bbc3e15584c2f3c74bcf064af36336c10d2e21a2131e123ce0f924c9"}, + {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ab2c2a26d2f69cdf833174f4d9d86118edc781ad9a8fa13970b527bf8236027"}, + {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e8921a259f54bfbc755c5bbd60c82bb2339ae0324163f32868f63f0ebb873d9"}, + {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a7ff941004d74d55a47f916afc38494bd1cfd4b53c482b77c03147c91ac0ac3"}, + {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5145282a7cd2ac16ea0dc46b82167754d5e103a05614b724457cffe614f25bd8"}, + {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de609a6f1b682f70bb7163da745ee815d8f230d97276db049ab447767466a09d"}, + {file = "rpds_py-0.21.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40c91c6e34cf016fa8e6b59d75e3dbe354830777fcfd74c58b279dceb7975b75"}, + {file = "rpds_py-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d2132377f9deef0c4db89e65e8bb28644ff75a18df5293e132a8d67748397b9f"}, + {file = "rpds_py-0.21.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0a9e0759e7be10109645a9fddaaad0619d58c9bf30a3f248a2ea57a7c417173a"}, + {file = "rpds_py-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e20da3957bdf7824afdd4b6eeb29510e83e026473e04952dca565170cd1ecc8"}, + {file = "rpds_py-0.21.0-cp311-none-win32.whl", hash = "sha256:f71009b0d5e94c0e86533c0b27ed7cacc1239cb51c178fd239c3cfefefb0400a"}, + {file = "rpds_py-0.21.0-cp311-none-win_amd64.whl", hash = "sha256:e168afe6bf6ab7ab46c8c375606298784ecbe3ba31c0980b7dcbb9631dcba97e"}, + {file = "rpds_py-0.21.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:30b912c965b2aa76ba5168fd610087bad7fcde47f0a8367ee8f1876086ee6d1d"}, + {file = "rpds_py-0.21.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ca9989d5d9b1b300bc18e1801c67b9f6d2c66b8fd9621b36072ed1df2c977f72"}, + {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f54e7106f0001244a5f4cf810ba8d3f9c542e2730821b16e969d6887b664266"}, + {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fed5dfefdf384d6fe975cc026886aece4f292feaf69d0eeb716cfd3c5a4dd8be"}, + {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590ef88db231c9c1eece44dcfefd7515d8bf0d986d64d0caf06a81998a9e8cab"}, + {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f983e4c2f603c95dde63df633eec42955508eefd8d0f0e6d236d31a044c882d7"}, + {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b229ce052ddf1a01c67d68166c19cb004fb3612424921b81c46e7ea7ccf7c3bf"}, + {file = "rpds_py-0.21.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ebf64e281a06c904a7636781d2e973d1f0926a5b8b480ac658dc0f556e7779f4"}, + {file = "rpds_py-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:998a8080c4495e4f72132f3d66ff91f5997d799e86cec6ee05342f8f3cda7dca"}, + {file = "rpds_py-0.21.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:98486337f7b4f3c324ab402e83453e25bb844f44418c066623db88e4c56b7c7b"}, + {file = "rpds_py-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a78d8b634c9df7f8d175451cfeac3810a702ccb85f98ec95797fa98b942cea11"}, + {file = "rpds_py-0.21.0-cp312-none-win32.whl", hash = "sha256:a58ce66847711c4aa2ecfcfaff04cb0327f907fead8945ffc47d9407f41ff952"}, + {file = "rpds_py-0.21.0-cp312-none-win_amd64.whl", hash = "sha256:e860f065cc4ea6f256d6f411aba4b1251255366e48e972f8a347cf88077b24fd"}, + {file = "rpds_py-0.21.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ee4eafd77cc98d355a0d02f263efc0d3ae3ce4a7c24740010a8b4012bbb24937"}, + {file = "rpds_py-0.21.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:688c93b77e468d72579351a84b95f976bd7b3e84aa6686be6497045ba84be560"}, + {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c38dbf31c57032667dd5a2f0568ccde66e868e8f78d5a0d27dcc56d70f3fcd3b"}, + {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d6129137f43f7fa02d41542ffff4871d4aefa724a5fe38e2c31a4e0fd343fb0"}, + {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:520ed8b99b0bf86a176271f6fe23024323862ac674b1ce5b02a72bfeff3fff44"}, + {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaeb25ccfb9b9014a10eaf70904ebf3f79faaa8e60e99e19eef9f478651b9b74"}, + {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af04ac89c738e0f0f1b913918024c3eab6e3ace989518ea838807177d38a2e94"}, + {file = "rpds_py-0.21.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b9b76e2afd585803c53c5b29e992ecd183f68285b62fe2668383a18e74abe7a3"}, + {file = "rpds_py-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5afb5efde74c54724e1a01118c6e5c15e54e642c42a1ba588ab1f03544ac8c7a"}, + {file = "rpds_py-0.21.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:52c041802a6efa625ea18027a0723676a778869481d16803481ef6cc02ea8cb3"}, + {file = "rpds_py-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee1e4fc267b437bb89990b2f2abf6c25765b89b72dd4a11e21934df449e0c976"}, + {file = "rpds_py-0.21.0-cp313-none-win32.whl", hash = "sha256:0c025820b78817db6a76413fff6866790786c38f95ea3f3d3c93dbb73b632202"}, + {file = "rpds_py-0.21.0-cp313-none-win_amd64.whl", hash = "sha256:320c808df533695326610a1b6a0a6e98f033e49de55d7dc36a13c8a30cfa756e"}, + {file = "rpds_py-0.21.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:2c51d99c30091f72a3c5d126fad26236c3f75716b8b5e5cf8effb18889ced928"}, + {file = "rpds_py-0.21.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbd7504a10b0955ea287114f003b7ad62330c9e65ba012c6223dba646f6ffd05"}, + {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6dcc4949be728ede49e6244eabd04064336012b37f5c2200e8ec8eb2988b209c"}, + {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f414da5c51bf350e4b7960644617c130140423882305f7574b6cf65a3081cecb"}, + {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9afe42102b40007f588666bc7de82451e10c6788f6f70984629db193849dced1"}, + {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b929c2bb6e29ab31f12a1117c39f7e6d6450419ab7464a4ea9b0b417174f044"}, + {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8404b3717da03cbf773a1d275d01fec84ea007754ed380f63dfc24fb76ce4592"}, + {file = "rpds_py-0.21.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e12bb09678f38b7597b8346983d2323a6482dcd59e423d9448108c1be37cac9d"}, + {file = "rpds_py-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:58a0e345be4b18e6b8501d3b0aa540dad90caeed814c515e5206bb2ec26736fd"}, + {file = "rpds_py-0.21.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c3761f62fcfccf0864cc4665b6e7c3f0c626f0380b41b8bd1ce322103fa3ef87"}, + {file = "rpds_py-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c2b2f71c6ad6c2e4fc9ed9401080badd1469fa9889657ec3abea42a3d6b2e1ed"}, + {file = "rpds_py-0.21.0-cp39-none-win32.whl", hash = "sha256:b21747f79f360e790525e6f6438c7569ddbfb1b3197b9e65043f25c3c9b489d8"}, + {file = "rpds_py-0.21.0-cp39-none-win_amd64.whl", hash = "sha256:0626238a43152918f9e72ede9a3b6ccc9e299adc8ade0d67c5e142d564c9a83d"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6b4ef7725386dc0762857097f6b7266a6cdd62bfd209664da6712cb26acef035"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6bc0e697d4d79ab1aacbf20ee5f0df80359ecf55db33ff41481cf3e24f206919"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da52d62a96e61c1c444f3998c434e8b263c384f6d68aca8274d2e08d1906325c"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98e4fe5db40db87ce1c65031463a760ec7906ab230ad2249b4572c2fc3ef1f9f"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30bdc973f10d28e0337f71d202ff29345320f8bc49a31c90e6c257e1ccef4333"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:faa5e8496c530f9c71f2b4e1c49758b06e5f4055e17144906245c99fa6d45356"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32eb88c30b6a4f0605508023b7141d043a79b14acb3b969aa0b4f99b25bc7d4a"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a89a8ce9e4e75aeb7fa5d8ad0f3fecdee813802592f4f46a15754dcb2fd6b061"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:241e6c125568493f553c3d0fdbb38c74babf54b45cef86439d4cd97ff8feb34d"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:3b766a9f57663396e4f34f5140b3595b233a7b146e94777b97a8413a1da1be18"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:af4a644bf890f56e41e74be7d34e9511e4954894d544ec6b8efe1e21a1a8da6c"}, + {file = "rpds_py-0.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3e30a69a706e8ea20444b98a49f386c17b26f860aa9245329bab0851ed100677"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:031819f906bb146561af051c7cef4ba2003d28cff07efacef59da973ff7969ba"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b876f2bc27ab5954e2fd88890c071bd0ed18b9c50f6ec3de3c50a5ece612f7a6"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc5695c321e518d9f03b7ea6abb5ea3af4567766f9852ad1560f501b17588c7b"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4de1da871b5c0fd5537b26a6fc6814c3cc05cabe0c941db6e9044ffbb12f04a"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:878f6fea96621fda5303a2867887686d7a198d9e0f8a40be100a63f5d60c88c9"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8eeec67590e94189f434c6d11c426892e396ae59e4801d17a93ac96b8c02a6c"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ff2eba7f6c0cb523d7e9cff0903f2fe1feff8f0b2ceb6bd71c0e20a4dcee271"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a429b99337062877d7875e4ff1a51fe788424d522bd64a8c0a20ef3021fdb6ed"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:d167e4dbbdac48bd58893c7e446684ad5d425b407f9336e04ab52e8b9194e2ed"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:4eb2de8a147ffe0626bfdc275fc6563aa7bf4b6db59cf0d44f0ccd6ca625a24e"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e78868e98f34f34a88e23ee9ccaeeec460e4eaf6db16d51d7a9b883e5e785a5e"}, + {file = "rpds_py-0.21.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4991ca61656e3160cdaca4851151fd3f4a92e9eba5c7a530ab030d6aee96ec89"}, + {file = "rpds_py-0.21.0.tar.gz", hash = "sha256:ed6378c9d66d0de903763e7706383d60c33829581f0adff47b6535f1802fa6db"}, +] + [[package]] name = "sentry-sdk" version = "2.17.0" @@ -880,6 +1384,17 @@ files = [ {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, ] +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +optional = false +python-versions = ">=3.6" +files = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] + [[package]] name = "urllib3" version = "2.2.3" @@ -925,4 +1440,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "9b90c3ab84c9ce2596c31f902fd6f3f9c7d99f57ce6d8c7bab0344ee85a5de88" +content-hash = "40439739d190ac3163f493a441e90f25e537bbbd096bf257f281ad5c96eebaab" diff --git a/humitifier-server/pyproject.toml b/humitifier-server/pyproject.toml index 0eefb52..4a59e10 100644 --- a/humitifier-server/pyproject.toml +++ b/humitifier-server/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "humitifier-server" -version = "0.1.0" +version = "3.2.0" description = "" authors = ["Mees, T.D. (Ty) "] @@ -16,6 +16,9 @@ django-debug-toolbar = "^4.4.6" django-simple-menu = "^2.1.3" whitenoise = "^6.8.2" sentry-sdk = {extras = ["django"], version = "^2.17.0"} +mozilla-django-oidc = "^4.0.1" +drf-spectacular = "^0.27.2" +django-oauth-toolkit = "^3.0.1" [tool.poetry.group.dev.dependencies] diff --git a/humitifier-server/src/api/filters.py b/humitifier-server/src/api/filters.py new file mode 100644 index 0000000..be423b3 --- /dev/null +++ b/humitifier-server/src/api/filters.py @@ -0,0 +1,11 @@ +import django_filters + +from api.models import OAuth2Application +from main.filters import FiltersForm + + +class OAuth2ApplicationFilters(django_filters.FilterSet): + class Meta: + model = OAuth2Application + fields = ['access_profile'] + form = FiltersForm \ No newline at end of file diff --git a/humitifier-server/src/api/forms.py b/humitifier-server/src/api/forms.py new file mode 100644 index 0000000..88bd5b8 --- /dev/null +++ b/humitifier-server/src/api/forms.py @@ -0,0 +1,33 @@ +from django import forms +from django.conf import settings + +from api.models import OAuth2Application + +class ScopeWidget(forms.CheckboxSelectMultiple): + + def format_value(self, value): + if value is None: + return [] + return value.split(',') + + def optgroups(self, name, value, attrs=None): + self.choices = [ + (scope, scope) + for scope in set(settings.OAUTH2_PROVIDER['SCOPES'].keys()) + ] + + return super().optgroups(name, value, attrs) + +class OAuth2ApplicationForm(forms.ModelForm): + class Meta: + model = OAuth2Application + fields = [ + 'name', + 'client_id', + 'client_secret', + 'access_profile', + 'allowed_scopes', + ] + widgets = { + 'allowed_scopes': ScopeWidget, + } \ No newline at end of file diff --git a/humitifier-server/src/api/menus.py b/humitifier-server/src/api/menus.py new file mode 100644 index 0000000..881f9af --- /dev/null +++ b/humitifier-server/src/api/menus.py @@ -0,0 +1,25 @@ +from django.urls import reverse +from simple_menu import Menu, MenuItem + + +Menu.add_item( + "main", + MenuItem( + "API documentation", + reverse("api:redoc"), + weight=22, + icon="icons/document.html", + separator=True, + ) +) + +Menu.add_item( + "main", + MenuItem( + "OAuth2 Applications", + reverse("api:oauth_applications"), + weight=23, + check=lambda request: request.user.is_superuser, + icon="icons/api.html", + ) +) diff --git a/humitifier-server/src/api/migrations/0001_initial.py b/humitifier-server/src/api/migrations/0001_initial.py new file mode 100644 index 0000000..a5c22a6 --- /dev/null +++ b/humitifier-server/src/api/migrations/0001_initial.py @@ -0,0 +1,137 @@ +# Generated by Django 5.1.2 on 2024-11-11 19:54 + +import django.contrib.postgres.fields +import django.db.models.deletion +import oauth2_provider.generators +import oauth2_provider.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("main", "0004_alter_user_default_home_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="OAuth2Application", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ( + "client_id", + models.CharField( + db_index=True, + default=oauth2_provider.generators.generate_client_id, + max_length=100, + unique=True, + ), + ), + ( + "redirect_uris", + models.TextField( + blank=True, help_text="Allowed URIs list, space separated" + ), + ), + ( + "post_logout_redirect_uris", + models.TextField( + blank=True, + default="", + help_text="Allowed Post Logout URIs list, space separated", + ), + ), + ( + "client_type", + models.CharField( + choices=[ + ("confidential", "Confidential"), + ("public", "Public"), + ], + max_length=32, + ), + ), + ( + "authorization_grant_type", + models.CharField( + choices=[ + ("authorization-code", "Authorization code"), + ("implicit", "Implicit"), + ("password", "Resource owner password-based"), + ("client-credentials", "Client credentials"), + ("openid-hybrid", "OpenID connect hybrid"), + ], + max_length=32, + ), + ), + ( + "client_secret", + oauth2_provider.models.ClientSecretField( + blank=True, + db_index=True, + default=oauth2_provider.generators.generate_client_secret, + help_text="Hashed on Save. Copy it now if this is a new secret.", + max_length=255, + ), + ), + ("hash_client_secret", models.BooleanField(default=True)), + ("name", models.CharField(blank=True, max_length=255)), + ("skip_authorization", models.BooleanField(default=False)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "algorithm", + models.CharField( + blank=True, + choices=[ + ("", "No OIDC support"), + ("RS256", "RSA with SHA-2 256"), + ("HS256", "HMAC with SHA-2 256"), + ], + default="", + max_length=5, + ), + ), + ( + "allowed_origins", + models.TextField( + blank=True, + default="", + help_text="Allowed origins list to enable CORS, space separated", + ), + ), + ( + "allowed_scopes", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=20), size=None + ), + ), + ( + "access_profile", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="oauth_applications", + to="main.accessprofile", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/humitifier-server/src/api/migrations/0002_alter_oauth2application_access_profile.py b/humitifier-server/src/api/migrations/0002_alter_oauth2application_access_profile.py new file mode 100644 index 0000000..2d4ab39 --- /dev/null +++ b/humitifier-server/src/api/migrations/0002_alter_oauth2application_access_profile.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.2 on 2024-11-12 12:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0001_initial"), + ("main", "0004_alter_user_default_home_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="oauth2application", + name="access_profile", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="oauth_applications", + to="main.accessprofile", + ), + ), + ] diff --git a/humitifier-server/src/api/models.py b/humitifier-server/src/api/models.py index 71a8362..3e0b570 100644 --- a/humitifier-server/src/api/models.py +++ b/humitifier-server/src/api/models.py @@ -1,3 +1,26 @@ +from django.conf import settings +from django.contrib.postgres.fields import ArrayField from django.db import models +from jsonschema.exceptions import ValidationError +from oauth2_provider.models import AbstractApplication -# Create your models here. + +class OAuth2Application(AbstractApplication): + access_profile = models.ForeignKey( + "main.AccessProfile", + on_delete=models.SET_NULL, + related_name="oauth_applications", + blank=True, + null=True, + ) + allowed_scopes = ArrayField( + models.CharField(max_length=20), + ) + + def clean(self): + super().clean() + if self.allowed_scopes: + allowed_scopes_set = set(self.allowed_scopes) + available_scopes_set = set(settings.OAUTH2_PROVIDER['SCOPES'].keys()) + if not allowed_scopes_set.issubset(available_scopes_set): + raise ValidationError("Invalid scopes in allowed_scopes") \ No newline at end of file diff --git a/humitifier-server/src/api/permissions.py b/humitifier-server/src/api/permissions.py new file mode 100644 index 0000000..c471570 --- /dev/null +++ b/humitifier-server/src/api/permissions.py @@ -0,0 +1,16 @@ +from oauth2_provider.models import AccessToken +from rest_framework import permissions + + +class TokenHasApplication(permissions.BasePermission): + def has_permission(self, request, view): + token = request.auth + if not token: + return False + + try: + access_token = AccessToken.objects.get(token=token) + request.application = access_token.application + return True + except AccessToken.DoesNotExist: + return False \ No newline at end of file diff --git a/humitifier-server/src/api/router.py b/humitifier-server/src/api/router.py new file mode 100644 index 0000000..e9e4f3a --- /dev/null +++ b/humitifier-server/src/api/router.py @@ -0,0 +1,7 @@ +from rest_framework.routers import DefaultRouter + +from api.views import HostsViewSet + +router = DefaultRouter() +router.register(r'hosts', HostsViewSet, basename='hosts') +urlpatterns = router.urls \ No newline at end of file diff --git a/humitifier-server/src/api/scopes.py b/humitifier-server/src/api/scopes.py new file mode 100644 index 0000000..c186aaf --- /dev/null +++ b/humitifier-server/src/api/scopes.py @@ -0,0 +1,16 @@ +from oauth2_provider.scopes import SettingsScopes + + +class OAuth2Scopes(SettingsScopes): + """Custom scopes backend, used to limit the scopes available to an application.""" + + def get_available_scopes(self, application=None, request=None, *args, **kwargs): + if application is None: + return [] + + return application.allowed_scopes + + def get_default_scopes(self, application=None, request=None, *args, **kwargs): + if application is None: + return [] + return application.allowed_scopes \ No newline at end of file diff --git a/humitifier-server/src/api/serializers.py b/humitifier-server/src/api/serializers.py new file mode 100644 index 0000000..5f36995 --- /dev/null +++ b/humitifier-server/src/api/serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from hosts.models import Host + + +class HostSerializer(serializers.ModelSerializer): + class Meta: + model = Host + fields = [ + 'fqdn', + 'created_at', + 'archived', + 'archival_date', + 'department', + 'contact', + 'os', + 'link', + ] + + link = serializers.HyperlinkedIdentityField( + view_name='hosts:detail', + lookup_field='fqdn' + ) \ No newline at end of file diff --git a/humitifier-server/src/api/tables.py b/humitifier-server/src/api/tables.py new file mode 100644 index 0000000..2766273 --- /dev/null +++ b/humitifier-server/src/api/tables.py @@ -0,0 +1,52 @@ +from django.urls import reverse + +from api.models import OAuth2Application +from main.easy_tables import BaseTable, ButtonColumn, CompoundColumn, \ + MethodColumn + + +class OAuth2ApplicationsTable(BaseTable): + class Meta: + model = OAuth2Application + columns = [ + 'name', + 'client_id', + 'access_profile', + 'allowed_scopes', + 'actions', + ] + column_breakpoint_overrides = { + 'client_id': 'md', + 'access_profile': 'md', + 'allowed_scopes': '2xl', + } + no_data_message = "No OAuth applications found. Please check your filters." + no_data_message_wild_wasteland = ("No apps found... What is an " + "Application anyway?") + + allowed_scopes = MethodColumn( + "Allowed scopes", + method_name='get_allowed_scopes' + ) + + @staticmethod + def get_allowed_scopes(obj: OAuth2Application): + return ', '.join(obj.allowed_scopes) + + actions = CompoundColumn( + "Actions", + columns=[ + ButtonColumn( + text='Edit', + button_class='btn light:btn-primary dark:btn-outline mr-2', + url=lambda obj: reverse('api:edit_oauth_application', + args=[obj.pk]), + ), + ButtonColumn( + text='Delete', + button_class='btn btn-danger', + url=lambda obj: reverse('api:delete_oauth_application', + args=[obj.pk]), + ), + ], + ) \ No newline at end of file diff --git a/humitifier-server/src/api/templates/api/application_confirm_delete.html b/humitifier-server/src/api/templates/api/application_confirm_delete.html new file mode 100644 index 0000000..b7488a2 --- /dev/null +++ b/humitifier-server/src/api/templates/api/application_confirm_delete.html @@ -0,0 +1,23 @@ +{% extends 'base/base_page_template.html' %} + +{% block content %} +
+

Delete access profile

+

+ Are you sure you want to delete the OAuth2 application "{{ object }}"? + All apps using these credentials will no longer be able to access the API! +

+
+ {% csrf_token %} + {{ form }} +
+ + Cancel + + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/api/templates/api/application_form.html b/humitifier-server/src/api/templates/api/application_form.html new file mode 100644 index 0000000..860703e --- /dev/null +++ b/humitifier-server/src/api/templates/api/application_form.html @@ -0,0 +1,25 @@ +{% extends 'base/base_page_template.html' %} + +{% block content %} +
+ {% if object %} +

Edit OAuth2 Application

+ {% else %} +

Create OAuth2 Application

+ {% endif %} + +
+ {% csrf_token %} + {{ form }} + +
+ + Cancel + + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/api/templates/api/application_list.html b/humitifier-server/src/api/templates/api/application_list.html new file mode 100644 index 0000000..c38bda4 --- /dev/null +++ b/humitifier-server/src/api/templates/api/application_list.html @@ -0,0 +1,24 @@ +{% extends "base/base_page_template.html" %} + +{% load humanize %} +{% load param_replace %} + +{% block page_title %}Hosts | {{ block.super }}{% endblock %} + +{% block content %} +
+
+

OAuth2 Applications

+ +
+ + {{ table.render }} +
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/api/urls.py b/humitifier-server/src/api/urls.py index 920acac..4908c8d 100644 --- a/humitifier-server/src/api/urls.py +++ b/humitifier-server/src/api/urls.py @@ -1,9 +1,39 @@ from django.urls import path +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView +from oauth2_provider import views as oauth_views -from .views import UploadScans +from .views import CreateOAuthApplicationView, DeleteOAuthApplicationView, \ + EditOAuthApplicationView, \ + InventorySync, UploadScans, \ + OAuthApplicationsView +from .router import urlpatterns as router_urlpatterns app_name = 'api' urlpatterns = [ + # API Endpoints path("upload_scans/", UploadScans.as_view(), name="upload_scans"), -] + path("inventory_sync/", InventorySync.as_view(), name="inventory_sync"), + + # OAuth2 + path("oauth/authorize/", oauth_views.AuthorizationView.as_view(), + name="authorize"), + path("oauth/token/", oauth_views.TokenView.as_view(), name="token"), + path("oauth/revoke_token/", oauth_views.RevokeTokenView.as_view(), name="revoke-token"), + path("oauth/introspect/", oauth_views.IntrospectTokenView.as_view(), name="introspect"), + path("oauth/applications/", OAuthApplicationsView.as_view(), + name="oauth_applications"), + path("oauth/applications/create/", CreateOAuthApplicationView.as_view(), + name="create_oauth_application"), + path("oauth/applications//edit/", + EditOAuthApplicationView.as_view(), + name="edit_oauth_application"), + path("oauth/applications//delete/", + DeleteOAuthApplicationView.as_view(), + name="delete_oauth_application"), + + # OpenAPI schema + Redoc + path('schema/', SpectacularAPIView.as_view(), name='schema'), + path('schema/redoc/', SpectacularRedocView.as_view(url_name='api:schema'), + name='redoc'), +] + router_urlpatterns \ No newline at end of file diff --git a/humitifier-server/src/api/views.py b/humitifier-server/src/api/views.py deleted file mode 100644 index 63db268..0000000 --- a/humitifier-server/src/api/views.py +++ /dev/null @@ -1,37 +0,0 @@ -from rest_framework.exceptions import APIException -from rest_framework.response import Response -from rest_framework.views import APIView - -from hosts.models import Host -from hosts.utils import historical_clean - - -# Create your views here. -class UploadScans(APIView): - """ - View to list all users in the system. - - * Requires token authentication. - * Only admin users are able to access this view. - """ - - def post(self, request, format=None): - """ - Return a list of all users. - """ - scans = request.data - hosts = [] - - if isinstance(scans, list): - for scan in scans: - if 'host' not in scan or 'data' not in scan: - raise APIException("Invalid scan data") - - host, created = Host.objects.get_or_create(fqdn=scan['host']) - host.add_scan(scan['data']) - - hosts.append(host) - - historical_clean() - - return Response("ok") \ No newline at end of file diff --git a/humitifier-server/src/api/views/__init__.py b/humitifier-server/src/api/views/__init__.py new file mode 100644 index 0000000..06df3de --- /dev/null +++ b/humitifier-server/src/api/views/__init__.py @@ -0,0 +1,3 @@ +from .system import * +from .read import * +from .applications import * \ No newline at end of file diff --git a/humitifier-server/src/api/views/applications.py b/humitifier-server/src/api/views/applications.py new file mode 100644 index 0000000..ab86f89 --- /dev/null +++ b/humitifier-server/src/api/views/applications.py @@ -0,0 +1,63 @@ +from braces.views import LoginRequiredMixin +from django.urls import reverse_lazy +from django.views.generic import CreateView, DeleteView, UpdateView + +from api.filters import OAuth2ApplicationFilters +from api.forms import OAuth2ApplicationForm +from api.models import OAuth2Application +from api.tables import OAuth2ApplicationsTable +from main.views import FilteredListView, SuperuserRequiredMixin, TableMixin + + +class OAuthApplicationsView( + LoginRequiredMixin, + SuperuserRequiredMixin, + TableMixin, + FilteredListView +): + model = OAuth2Application + table_class = OAuth2ApplicationsTable + filterset_class = OAuth2ApplicationFilters + paginate_by = 50 + template_name = 'api/application_list.html' + ordering_fields = { + 'name': 'Name', + 'access_profile': 'Access Profile', + } + +class CreateOAuthApplicationView( + LoginRequiredMixin, + SuperuserRequiredMixin, + CreateView +): + model = OAuth2Application + form_class = OAuth2ApplicationForm + template_name = 'api/application_form.html' + success_url = reverse_lazy('api:oauth_applications') + + def form_valid(self, form): + # We only allow confidential clients with client credentials grant type + form.instance.client_type = OAuth2Application.CLIENT_CONFIDENTIAL + form.instance.authorization_grant_type = OAuth2Application.GRANT_CLIENT_CREDENTIALS + return super().form_valid(form) + + +class EditOAuthApplicationView( + LoginRequiredMixin, + SuperuserRequiredMixin, + UpdateView +): + model = OAuth2Application + form_class = OAuth2ApplicationForm + template_name = 'api/application_form.html' + success_url = reverse_lazy('api:oauth_applications') + + +class DeleteOAuthApplicationView( + LoginRequiredMixin, + SuperuserRequiredMixin, + DeleteView +): + model = OAuth2Application + template_name = 'api/application_confirm_delete.html' + success_url = reverse_lazy('api:oauth_applications') \ No newline at end of file diff --git a/humitifier-server/src/api/views/read.py b/humitifier-server/src/api/views/read.py new file mode 100644 index 0000000..e2fb737 --- /dev/null +++ b/humitifier-server/src/api/views/read.py @@ -0,0 +1,31 @@ +from django_filters.rest_framework import DjangoFilterBackend +from oauth2_provider.contrib.rest_framework import TokenHasScope +from rest_framework import viewsets + +from api.permissions import TokenHasApplication +from api.serializers import HostSerializer +from hosts.filters import HostFilters +from hosts.models import Host + + +class HostsViewSet(viewsets.ReadOnlyModelViewSet): + """ + A viewset for viewing and editing user instances. + """ + permission_classes = [ + TokenHasApplication, + TokenHasScope + ] + required_scopes = ['read'] + serializer_class = HostSerializer + lookup_field = 'fqdn' + filter_backends = [DjangoFilterBackend] + filterset_class = HostFilters + + def get_queryset(self): + # Needed for DRF Spectacular's introspection; + # The attribute is set in the TokenHasApplication permission + if not hasattr(self.request, 'application'): + return Host.objects.none() + app = self.request.application + return Host.objects.get_for_application(app) \ No newline at end of file diff --git a/humitifier-server/src/api/views/system.py b/humitifier-server/src/api/views/system.py new file mode 100644 index 0000000..5613294 --- /dev/null +++ b/humitifier-server/src/api/views/system.py @@ -0,0 +1,78 @@ +from drf_spectacular.utils import extend_schema, OpenApiTypes, inline_serializer +from oauth2_provider.contrib.rest_framework import TokenHasScope +from rest_framework import serializers +from rest_framework.exceptions import APIException +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.permissions import TokenHasApplication +from hosts.models import Host +from hosts.utils import historical_clean + +class UploadScans(APIView): + permission_classes = [TokenHasApplication, TokenHasScope] + required_scopes = ['system'] + + @extend_schema( + operation_id="upload_scans", + request=inline_serializer( + "Scan", + fields={ + "host": serializers.CharField(required=True), + "data": serializers.JSONField(required=True), + }, + many=True, + ), + responses={ + 200: OpenApiTypes.INT + }, + ) + def post(self, request, format=None): + """ + Upload one or more scans for a host + """ + scans = request.data + hosts = [] + + if not isinstance(scans, list): + scans = [scans] + + for scan in scans: + if 'host' not in scan or 'data' not in scan: + raise APIException("Invalid scan data") + + host, created = Host.objects.get_or_create(fqdn=scan['host']) + host.add_scan(scan['data']) + + hosts.append(host) + + historical_clean() + + return Response(len(scans)) + +class InventorySync(APIView): + permission_classes = [TokenHasApplication, TokenHasScope] + required_scopes = ['system'] + + @extend_schema( + operation_id="inventory_sync", + request=inline_serializer( + "InventorySync", + fields={ + "inventory": serializers.UUIDField(required=True), + "hosts": serializers.ListField( + child=serializers.CharField(), + required=True, + ), + }, + ), + responses={ + 200: OpenApiTypes.INT + }, + ) + def post(self, request, format=None): + """ + Sync inventory data + """ + # TODO: implement :D + raise NotImplementedError("Not implemented") diff --git a/humitifier-server/src/hosts/filters.py b/humitifier-server/src/hosts/filters.py index 81adf20..457697d 100644 --- a/humitifier-server/src/hosts/filters.py +++ b/humitifier-server/src/hosts/filters.py @@ -1,11 +1,12 @@ -from collections import OrderedDict - import django_filters from django.db.models.expressions import RawSQL -from django.forms import Form from django_filters import ChoiceFilter +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from hosts.models import Alert, AlertLevel, AlertType, Host +from main.filters import FiltersForm + def _get_choices(field, strip_quotes=True): # The empty order_by() is required to remove the default ordering @@ -39,7 +40,9 @@ def filter(self, qs, value): return qs class PackageFilter(django_filters.Filter): - + _spectacular_annotation = { + 'field': OpenApiTypes.STR, + } def filter(self, qs, value): if value: qs = qs.annotate( @@ -91,14 +94,6 @@ def filter(self, qs, value): return qs -class FiltersForm(Form): - template_name = 'base/page_parts/filters_form_template.html' - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - for field_name, field in self.fields.items(): - self.fields[field_name].widget.attrs['placeholder'] = field.label class HostFilters(django_filters.FilterSet): diff --git a/humitifier-server/src/hosts/menus.py b/humitifier-server/src/hosts/menus.py index 1d809f4..1fc7213 100644 --- a/humitifier-server/src/hosts/menus.py +++ b/humitifier-server/src/hosts/menus.py @@ -9,6 +9,7 @@ weight=10, icon="icons/host.html", separator=True, + check=lambda request: request.user.is_authenticated, ) ) @@ -19,6 +20,7 @@ reverse("hosts:tasks"), weight=10, icon="icons/tasks.html", + check=lambda request: request.user.is_superuser, ) ) @@ -29,6 +31,7 @@ reverse("hosts:scan_profiles"), weight=10, icon="icons/terminal.html", + check=lambda request: request.user.is_superuser, ) ) @@ -39,5 +42,6 @@ reverse("hosts:data_sources"), weight=10, icon="icons/databases.html", + check=lambda request: request.user.is_superuser, ) ) diff --git a/humitifier-server/src/hosts/migrations/0009_alter_host_created_at_alter_host_fqdn.py b/humitifier-server/src/hosts/migrations/0009_alter_host_created_at_alter_host_fqdn.py new file mode 100644 index 0000000..694a506 --- /dev/null +++ b/humitifier-server/src/hosts/migrations/0009_alter_host_created_at_alter_host_fqdn.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2024-11-06 12:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("hosts", "0008_host_archival_date_host_archived"), + ] + + operations = [ + migrations.AlterField( + model_name="host", + name="created_at", + field=models.DateTimeField(auto_now_add=True, verbose_name="Registered"), + ), + migrations.AlterField( + model_name="host", + name="fqdn", + field=models.CharField(max_length=255, verbose_name="Hostname"), + ), + ] diff --git a/humitifier-server/src/hosts/models.py b/humitifier-server/src/hosts/models.py index 6bc4497..3864932 100644 --- a/humitifier-server/src/hosts/models.py +++ b/humitifier-server/src/hosts/models.py @@ -2,6 +2,8 @@ from django.db.models import Case, F, Value, When from django.utils.safestring import mark_safe +from api.models import OAuth2Application +from main.models import User from main.templatetags.strip_quotes import strip_quotes @@ -40,9 +42,29 @@ def _json_value(field: str): class HostManager(models.Manager): - def get_for_user(self, user): - # TODO: implement this when we have user support - return self.get_queryset() + def get_for_user(self, user: User): + if user.is_anonymous: + return self.get_queryset().none() + + if user.is_superuser: + return self.get_queryset() + + if user.access_profiles.exists(): + return self.get_queryset().filter( + department__in=user.departments_for_filter + ) + + # When a non-superuser has no access profile, THEY GET NOTHING + # They lose! Good day sir! + return self.get_queryset().none() + + def get_for_application(self, application: OAuth2Application): + if application.access_profile is None: + return self.get_queryset().none() + + return self.get_queryset().filter( + department__in=application.access_profile.departments_for_filter + ) class Host(models.Model): @@ -51,7 +73,10 @@ class Meta: objects = HostManager() - fqdn = models.CharField(max_length=255) + fqdn = models.CharField( + "Hostname", + max_length=255 + ) last_scan_cache = models.JSONField( null=True, @@ -61,7 +86,10 @@ class Meta: null=True, ) - created_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField( + "Registered", + auto_now_add=True, + ) protected = models.BooleanField(default=False) @@ -134,15 +162,32 @@ def regenerate_alerts(self): @property def num_critical_alerts(self): - return self.alerts.filter(level=AlertLevel.CRITICAL).count() + return self._get_alerts_for_level(AlertLevel.CRITICAL, count=True) @property def num_warning_alerts(self): - return self.alerts.filter(level=AlertLevel.WARNING).count() + return self._get_alerts_for_level(AlertLevel.WARNING, count=True) @property def num_info_alerts(self): - return self.alerts.filter(level=AlertLevel.INFO).count() + return self._get_alerts_for_level(AlertLevel.INFO, count=True) + + def _get_alerts_for_level(self, level, count=False): + # Use the prefetched objects if they are available + # It's not quicker for a single query, but it is for multiple queries + # (Read: the list page) + if 'alerts' in self._prefetched_objects_cache: + alerts = [alert for alert in self.alerts.all() if (alert.level == + level)] + if count: + return len(alerts) + return alerts + + qs = self.alerts.filter(level=level) + + if count: + return qs.count() + return qs ## ## Display methods @@ -190,9 +235,21 @@ class Meta: class AlertManager(models.Manager): - def get_for_user(self, user): - # TODO: implement this when we have user support - return self.get_queryset() + def get_for_user(self, user: User): + if user.is_anonymous: + return self.get_queryset().none() + + if user.is_superuser: + return self.get_queryset() + + if user.access_profiles.exists(): + return self.get_queryset().filter( + host__department__in=user.departments_for_filter + ) + + # When a non-superuser has no access profile, THEY GET NOTHING + # They lose! Good day sir! + return self.get_queryset().none() class Alert(models.Model): diff --git a/humitifier-server/src/hosts/tables.py b/humitifier-server/src/hosts/tables.py new file mode 100644 index 0000000..4f51e93 --- /dev/null +++ b/humitifier-server/src/hosts/tables.py @@ -0,0 +1,38 @@ +from django.urls import reverse + +from hosts.models import Host +from main.easy_tables import BaseTable, DateTimeColumn, LinkColumn, TemplatedColumn + + +class HostsTable(BaseTable): + class Meta: + model = Host + columns = [ + 'fqdn', + 'os', + 'last_scan_date', + 'created_at', + 'department', + 'status', + ] + column_type_overrides = { + 'fqdn': LinkColumn( + url=lambda obj: reverse('hosts:detail', args=[obj.fqdn]), + text=lambda obj: obj.fqdn, + ), + 'last_scan_date': DateTimeColumn, + 'created_at': DateTimeColumn, + } + column_breakpoint_overrides = { + 'os': 'lg', + 'last_scan_date': 'xl', + 'created_at': '2xl', + 'department': 'lg', + } + no_data_message = "No hosts found. Please check your filters." + no_data_message_wild_wasteland = "Oh no! Where have our hosts gone?" + + status = TemplatedColumn( + "Status", + template_name='hosts/list_parts/host_status.html', + ) \ No newline at end of file diff --git a/humitifier-server/src/hosts/templates/hosts/export.html b/humitifier-server/src/hosts/templates/hosts/export.html new file mode 100644 index 0000000..1936291 --- /dev/null +++ b/humitifier-server/src/hosts/templates/hosts/export.html @@ -0,0 +1,28 @@ +{% extends "base/base_page_template.html" %} + +{% load humanize %} +{% load param_replace %} + +{% block page_title %}Hosts | {{ block.super }}{% endblock %} + +{% block content %} +
+
+

Export host data

+
+

+ Here you can export the host data in CSV format. You can filter the data before exporting. + Note: in the future there might be more fields to export. +

+
+

Filters

+
+
+ {% csrf_token %} + {{ filterset.form }} + +
+
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/hosts/templates/hosts/list.html b/humitifier-server/src/hosts/templates/hosts/list.html index 27cf4e7..264ed36 100644 --- a/humitifier-server/src/hosts/templates/hosts/list.html +++ b/humitifier-server/src/hosts/templates/hosts/list.html @@ -19,54 +19,6 @@

Hosts

-
- {% include 'base/page_parts/paginator_top.html' %} -
- - - - - - - - - - - - - {% for host in object_list %} - {% with host=host %} - {% include 'hosts/list_parts/host.html' %} - {% endwith %} - {% endfor %} - {% if object_list.count == 0 %} - - - - {% endif %} - -
- Hostname - - Status -
- {% if layout.wild_wasteland %} - No hosts found, have you looked under the rug? - {% else %} - No hosts found - {% endif %} -
- -
- {% include 'base/page_parts/paginator_bottom.html' %} -
+ {{ table.render }} {% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/hosts/templates/hosts/list_parts/host.html b/humitifier-server/src/hosts/templates/hosts/list_parts/host.html deleted file mode 100644 index 2010b2e..0000000 --- a/humitifier-server/src/hosts/templates/hosts/list_parts/host.html +++ /dev/null @@ -1,41 +0,0 @@ -{% load strip_quotes %} - - - - {{ host.fqdn }} - - - - {{ host.get_os_display }} - - - {{ host.last_scan_date|date:"Y-m-d H:i" }} - - - {{ host.created_at|date:"Y-m-d H:i" }} - - - {{ host.get_department_display }} - - -
- {% if host.alerts.count %} - {% if host.num_info_alerts %} -
- {% include 'icons/info.html' %} {{ host.num_info_alerts }} -
- {% endif %} - {% if host.num_warning_alerts %} -
- {% include 'icons/warning.html' %} {{ host.num_warning_alerts }} -
- {% endif %} - {% if host.num_critical_alerts %} -
- {% include 'icons/critical.html' %} {{ host.num_critical_alerts }} -
- {% endif %} - {% endif %} -
- - diff --git a/humitifier-server/src/hosts/templates/hosts/list_parts/host_status.html b/humitifier-server/src/hosts/templates/hosts/list_parts/host_status.html new file mode 100644 index 0000000..3582c3f --- /dev/null +++ b/humitifier-server/src/hosts/templates/hosts/list_parts/host_status.html @@ -0,0 +1,19 @@ +
+ {% if obj.alerts.count %} + {% if obj.num_info_alerts %} +
+ {% include 'icons/info.html' %} {{ obj.num_info_alerts }} +
+ {% endif %} + {% if obj.num_warning_alerts %} +
+ {% include 'icons/warning.html' %} {{ obj.num_warning_alerts }} +
+ {% endif %} + {% if obj.num_critical_alerts %} +
+ {% include 'icons/critical.html' %} {{ obj.num_critical_alerts }} +
+ {% endif %} + {% endif %} +
\ No newline at end of file diff --git a/humitifier-server/src/hosts/urls.py b/humitifier-server/src/hosts/urls.py index de0ba54..edddb9a 100644 --- a/humitifier-server/src/hosts/urls.py +++ b/humitifier-server/src/hosts/urls.py @@ -1,19 +1,19 @@ from django.urls import path -from .views import ArchiveHostView, DataSourcesView, ExportView, HostDetailView, \ - HostsListView, \ +from .views import ArchiveHostView, DataSourcesView, HostDetailView, \ + HostExportView, HostsListView, \ HostsRawDownloadView, ScanProfilesView, TasksView app_name = 'hosts' urlpatterns = [ path("", HostsListView.as_view(), name="list"), + path("export/", HostExportView.as_view(), name="export"), path("host//", HostDetailView.as_view(), name="detail"), path("host//raw/", HostsRawDownloadView.as_view(), name="download_raw"), path("host//archive/", ArchiveHostView.as_view(), name="archive"), - path("export/", ExportView.as_view(), name="export"), path("tasks/", TasksView.as_view(), name="tasks"), path("scan-profiles/", ScanProfilesView.as_view(), name="scan_profiles"), path("data-sources/", DataSourcesView.as_view(), name="data_sources"), diff --git a/humitifier-server/src/hosts/views.py b/humitifier-server/src/hosts/views.py index 706805c..0c0e193 100644 --- a/humitifier-server/src/hosts/views.py +++ b/humitifier-server/src/hosts/views.py @@ -1,5 +1,8 @@ +import csv import json +from io import StringIO +from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.forms import Form from django.http import HttpResponse, HttpResponseRedirect @@ -11,15 +14,16 @@ SingleObjectTemplateResponseMixin from django.views.generic.edit import FormMixin -from main.views import FilteredListView +from main.views import FilteredListView, SuperuserRequiredMixin, TableMixin from .filters import HostFilters from .models import Host +from .tables import HostsTable -# Create your views here. -class HostsListView(FilteredListView): +class HostsListView(LoginRequiredMixin, TableMixin, FilteredListView): model = Host + table_class = HostsTable filterset_class = HostFilters paginate_by = 50 template_name = 'hosts/list.html' @@ -49,7 +53,54 @@ def get_queryset(self): return filtered_qs.distinct() -class HostDetailView(TemplateView): + +class HostExportView(LoginRequiredMixin, FilteredListView): + model = Host + filterset_class = HostFilters + template_name = 'hosts/export.html' + + def get_queryset(self): + queryset = Host.objects.get_for_user(self.request.user) + + data = self.request.GET.copy() + data.update(self.request.POST) + + self.filterset = self.filterset_class(data, queryset=queryset) + + return self.filterset.qs.distinct() + + def post(self, request, *args, **kwargs): + buffer = StringIO() + csv_file = csv.DictWriter( + f=buffer, + fieldnames={ + 'fqdn': 'Hostname', + 'os': 'Operating System', + 'department': 'Department', + 'contact': 'Contact', + 'created_at': 'Created At', + 'archived': 'Archived', + 'archival_date': 'Archival Date', + 'last_scan_date': 'Last Scan Date', + } + ) + csv_file.writeheader() + for host in self.get_queryset(): + csv_file.writerow({ + 'fqdn': host.fqdn, + 'os': host.os, + 'department': host.department, + 'contact': host.contact, + 'created_at': host.created_at, + 'archived': host.archived, + 'archival_date': host.archival_date, + 'last_scan_date': host.last_scan_date, + }) + + return HttpResponse(buffer.getvalue(), content_type='text/csv') + + +class HostDetailView(LoginRequiredMixin, TemplateView): template_name = 'hosts/detail.html' LATEST_KEY = 'latest' @@ -88,7 +139,7 @@ def get_context_data(self, **kwargs): return context -class HostsRawDownloadView(View): +class HostsRawDownloadView(LoginRequiredMixin, View): def get(self, request, fqdn): host = Host.objects.get_for_user(request.user).get(fqdn=fqdn) @@ -113,6 +164,8 @@ def get(self, request, fqdn): class ArchiveHostView( + LoginRequiredMixin, + SuperuserRequiredMixin, SingleObjectTemplateResponseMixin, FormMixin, BaseDetailView @@ -154,14 +207,11 @@ def form_valid(self, form): return HttpResponseRedirect(success_url) -class ExportView(TemplateView): - template_name = 'main/not_implemented.html' - -class TasksView(TemplateView): +class TasksView(LoginRequiredMixin, SuperuserRequiredMixin, TemplateView): template_name = 'main/not_implemented.html' -class ScanProfilesView(TemplateView): +class ScanProfilesView(LoginRequiredMixin, SuperuserRequiredMixin, TemplateView): template_name = 'main/not_implemented.html' -class DataSourcesView(TemplateView): +class DataSourcesView(LoginRequiredMixin, SuperuserRequiredMixin, TemplateView): template_name = 'main/not_implemented.html' \ No newline at end of file diff --git a/humitifier-server/src/humitifier_server/oidc_backend.py b/humitifier-server/src/humitifier_server/oidc_backend.py new file mode 100644 index 0000000..d0578e3 --- /dev/null +++ b/humitifier-server/src/humitifier_server/oidc_backend.py @@ -0,0 +1,35 @@ +from mozilla_django_oidc.auth import OIDCAuthenticationBackend + + +class HumitifierOIDCAuthenticationBackend(OIDCAuthenticationBackend): + + def create_user(self, claims): + email = claims.get('email') + first_name = claims.get('given_name') + last_name = claims.get('family_name') + username = self.get_username(claims) + + return self.UserModel.objects.create_user(username, email=email, + first_name=first_name, last_name=last_name) + + def filter_users_by_claims(self, claims): + username = self.get_username(claims) + if not username: + return self.UserModel.objects.none() + return self.UserModel.objects.filter(username=username, is_local_account=False) + + def get_username(self, claims): + return claims.get('preferred_username') + + def update_user(self, user, claims): + email = claims.get('email') + first_name = claims.get('given_name') + last_name = claims.get('family_name') + + user.email = email + user.first_name = first_name + user.last_name = last_name + + user.save() + + return user \ No newline at end of file diff --git a/humitifier-server/src/humitifier_server/settings.py b/humitifier-server/src/humitifier_server/settings.py index 1817d34..6c80b5c 100644 --- a/humitifier-server/src/humitifier_server/settings.py +++ b/humitifier-server/src/humitifier_server/settings.py @@ -9,8 +9,13 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.1/ref/settings/ """ - from pathlib import Path +import re +from urllib.parse import urlparse + +from django.core.exceptions import ImproperlyConfigured +from rest_framework.reverse import reverse_lazy + from . import env # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -54,6 +59,8 @@ # Third-party apps "rest_framework", "simple_menu", + "drf_spectacular", + "oauth2_provider", # Local apps "hosts", "api", @@ -116,6 +123,111 @@ } } +# Authentication + +LOGIN_URL = reverse_lazy("main:login") +LOGIN_REDIRECT_URL = reverse_lazy("main:home") +LOGOUT_REDIRECT_URL = reverse_lazy("main:home") + +## OpenID Connect + +if env.get_boolean("DJANGO_OIDC_ENABLED", default=False): + try: + index = INSTALLED_APPS.index('django.contrib.auth') + INSTALLED_APPS.insert(index + 1, "mozilla_django_oidc") + except ValueError: + raise ImproperlyConfigured( + "Cannot enable OIDC; django.contrib.auth is not enabled" + ) + + if env.get_boolean("DJANGO_OIDC_SESSION_REFRESH", default=False): + MIDDLEWARE.append("mozilla_django_oidc.middleware.SessionRefresh") + + AUTHENTICATION_BACKENDS = [ + "humitifier_server.oidc_backend.HumitifierOIDCAuthenticationBackend", + "django.contrib.auth.backends.ModelBackend", + ] + + OIDC_EXEMPT_URLS = [ + re.compile(r"^api/.*$"), + ] + + OIDC_CREATE_USER = env.get_boolean("OIDC_CREATE_USER", default=False) + OIDC_RP_SCOPES = env.get("OIDC_RP_SCOPES", default="openid email profile") + + OIDC_RP_SIGN_ALGO = env.get("OIDC_RP_SIGN_ALGO", default="RS256") + + OIDC_AUTH_REQUEST_EXTRA_PARAMS = {} + + _acr_values = env.get("OIDC_RP_ACR_VALUES", default=None) + + if _acr_values: + OIDC_AUTH_REQUEST_EXTRA_PARAMS["acr_values"] = _acr_values + + if client_id :=env.get("OIDC_RP_CLIENT_ID", default=None): + OIDC_RP_CLIENT_ID = client_id + else: + raise ImproperlyConfigured("OIDC_RP_CLIENT_ID is required") + + if client_secret := env.get("OIDC_RP_CLIENT_SECRET", default=None): + OIDC_RP_CLIENT_SECRET = client_secret + else: + raise ImproperlyConfigured("OIDC_RP_CLIENT_SECRET is required") + + if jwk_endpoint := env.get("OIDC_OP_JWKS_ENDPOINT", default=None): + OIDC_OP_JWKS_ENDPOINT = jwk_endpoint + else: + raise ImproperlyConfigured("OIDC_OP_JWKS_ENDPOINT is required") + + if auth_endpoint := env.get("OIDC_OP_AUTHORIZATION_ENDPOINT", default=None): + OIDC_OP_AUTHORIZATION_ENDPOINT = auth_endpoint + else: + raise ImproperlyConfigured("OIDC_OP_AUTHORIZATION_ENDPOINT is required") + + if token_endpoint := env.get("OIDC_OP_TOKEN_ENDPOINT", default=None): + OIDC_OP_TOKEN_ENDPOINT = token_endpoint + else: + raise ImproperlyConfigured("OIDC_OP_TOKEN_ENDPOINT is required") + + if user_endpoint := env.get("OIDC_OP_USER_ENDPOINT", default=None): + OIDC_OP_USER_ENDPOINT = user_endpoint + else: + raise ImproperlyConfigured("OIDC_OP_USER_ENDPOINT is required") + +# DRF + +REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', + ), +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Humitifier API', + 'DESCRIPTION': 'API for Humitifier, the Hum-IT CMDB', + 'VERSION': '3.2.0', + 'SERVE_INCLUDE_SCHEMA': False, + # OAuth2 + 'OAUTH2_FLOWS': ['clientCredentials'], + 'OAUTH2_AUTHORIZATION_URL': reverse_lazy('api:authorize'), + 'OAUTH2_TOKEN_URL': reverse_lazy('api:token'), + 'OAUTH2_REFRESH_URL': reverse_lazy('api:token'), + 'OAUTH2_SCOPES': None, + # OTHER SETTINGS +} + +## OAuth2 + +OAUTH2_PROVIDER_APPLICATION_MODEL = 'api.OAuth2Application' + +OAUTH2_PROVIDER = { + "SCOPES": { + "read": "Read scope", + "system": "System scope", + }, + "SCOPES_BACKEND_CLASS": "api.scopes.OAuth2Scopes", +} # Security @@ -222,6 +334,18 @@ import sentry_sdk + +def before_send(event, hint): + if "request" in event and "url" in event["request"]: + url_string = event["request"]["url"] + parsed_url = urlparse(url_string) + hostname = parsed_url.hostname + + if hostname not in ALLOWED_HOSTS: + return None + + return event + DSN = env.get("SENTRY_DSN", default=None) if DSN: sentry_sdk.init( @@ -230,4 +354,5 @@ _experiments={ "continuous_profiling_auto_start": True, }, + before_send=before_send, ) diff --git a/humitifier-server/src/humitifier_server/urls.py b/humitifier-server/src/humitifier_server/urls.py index 2a989a3..43b48d0 100644 --- a/humitifier-server/src/humitifier_server/urls.py +++ b/humitifier-server/src/humitifier_server/urls.py @@ -18,6 +18,8 @@ from django.conf import settings from django.contrib import admin from django.urls import include, path +from mozilla_django_oidc.urls import OIDCAuthenticateClass, OIDCCallbackClass +from mozilla_django_oidc.views import OIDCLogoutView urlpatterns = [ path("admin/", admin.site.urls), @@ -31,4 +33,20 @@ urlpatterns = [ path("__debug__/", include(debug_toolbar.urls)), - ] + urlpatterns \ No newline at end of file + ] + urlpatterns + +if hasattr(settings, 'OIDC_RP_CLIENT_ID'): + # Custom OIDC url conf, because we're hijacking an existing RP config + urlpatterns += [ + path( + "redirect_uri", # NO TRAILING SLASH. IMPORTANT! + OIDCCallbackClass.as_view(), + name="oidc_authentication_callback" + ), + path( + "oidc/authenticate/", + OIDCAuthenticateClass.as_view(), + name="oidc_authentication_init", + ), + path("oidc/logout/", OIDCLogoutView.as_view(), name="oidc_logout"), + ] \ No newline at end of file diff --git a/humitifier-server/src/main/context_processors.py b/humitifier-server/src/main/context_processors.py index a7867de..bc2b557 100644 --- a/humitifier-server/src/main/context_processors.py +++ b/humitifier-server/src/main/context_processors.py @@ -16,7 +16,11 @@ def layout_context(request): tag_line = "HumIT CMDB" - wild_wasteland = settings.DEBUG # TODO: user setting + wild_wasteland = False + if user.is_authenticated: + wild_wasteland = user.wild_wasteland_mode + + oidc_enabled = hasattr(settings, "OIDC_RP_CLIENT_ID") if wild_wasteland: jokes = [ @@ -40,6 +44,7 @@ def layout_context(request): "num_info_alerts": all_alerts.filter(level=AlertLevel.INFO).count(), "num_warning_alerts": all_alerts.filter(level=AlertLevel.WARNING).count(), "num_critical_alerts": all_alerts.filter(level=AlertLevel.CRITICAL).count(), + "oidc_enabled": oidc_enabled, "wild_wasteland": wild_wasteland, "tag_line": tag_line, } diff --git a/humitifier-server/src/main/easy_tables/__init__.py b/humitifier-server/src/main/easy_tables/__init__.py new file mode 100644 index 0000000..b57f8c7 --- /dev/null +++ b/humitifier-server/src/main/easy_tables/__init__.py @@ -0,0 +1,2 @@ +from .tables import BaseTable +from .columns import * diff --git a/humitifier-server/src/main/easy_tables/columns.py b/humitifier-server/src/main/easy_tables/columns.py new file mode 100644 index 0000000..0eca8f0 --- /dev/null +++ b/humitifier-server/src/main/easy_tables/columns.py @@ -0,0 +1,286 @@ +from typing import Callable + +from django.template.defaultfilters import date +from django.template.loader import render_to_string +from django.utils.safestring import mark_safe +from django_filters.conf import is_callable + +class BaseColumn: + + def __init__( + self, + header: str | None = None, + mark_safe: bool = False, + hide_breakpoint: str | None = None, + column_classes: list[str] | None = None, + **kwargs, + ): + self.header = header + self.mark_safe = mark_safe + self.hide_breakpoint = hide_breakpoint + self._column_classes = column_classes or [] + + self.table = None + + self.extra = kwargs + + @property + def column_classes(self): + classes = self._column_classes.copy() + + if self.hide_breakpoint: + classes.append('hidden') + classes.append(f'{self.hide_breakpoint}:table-cell') + + return ' '.join(classes) + + @property + def header_classes(self): + classes = [] + + if self.hide_breakpoint: + classes.append('hidden') + classes.append(f'{self.hide_breakpoint}:table-cell') + + return ' '.join(classes) + + def contribute_to_class(self, cls): + self.table = cls + + def render(self, obj): + raise NotImplementedError('Subclasses must implement this method') + +class MethodColumn(BaseColumn): + + def __init__( + self, + *args, + method_name: str, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.method_name = method_name + + def render(self, obj): + method = getattr(self.table, self.method_name, None) + + if not method: + return '' + + return method(obj) + + +class ValueColumn(BaseColumn): + + def __init__( + self, + *args, + value_attr: Callable | str = None, + default_value: str = '', + **kwargs, + ): + super().__init__(*args, **kwargs) + self.value_attr = value_attr + self.default_value = default_value + + def get_value(self, obj): + if isinstance(obj, dict): + if self.value_attr in obj: + value = obj[self.value_attr] + else: + value = None + else: + value = getattr(obj, self.value_attr, None) + + if not value: + return self.default_value + + if is_callable(value): + value = value() + + return value + + def render(self, obj): + value = self.get_value(obj) + + if self.mark_safe: + return mark_safe(value) + + return value + + +class ModelValueColumn(ValueColumn): + + def get_value(self, obj): + if hasattr(obj, f"get_{self.value_attr}_display"): + return getattr(obj, f"get_{self.value_attr}_display")() + + return getattr(obj, self.value_attr) + + +class BooleanColumn(ValueColumn): + + def __init__(self, *args, yes_no_values = None, **kwargs): + super().__init__(*args, **kwargs) + self.yes_no_values = yes_no_values or {True: 'Yes', False: 'No', + None: 'Unknown'} + + def render(self, obj): + value = self.get_value(obj) + + # We don't do a direct dict lookup, as we want to handle 'truthy' and + # 'falsy' values as well. + + if value is None: + return self.yes_no_values[None] + + if value: + return self.yes_no_values[True] + + return self.yes_no_values[False] + + +class CompoundColumn(BaseColumn): + + def __init__( + self, + *args, + columns: list[BaseColumn], + **kwargs, + ): + super().__init__(*args, **kwargs) + self.columns = columns + + def render(self, obj): + output = [ + column.render(obj) for column in self.columns + ] + return mark_safe(' '.join(output)) + + +class DateColumn(ValueColumn): + + def __init__( + self, + *args, + date_format: str = 'Y-m-d', + **kwargs, + ): + super().__init__(*args, **kwargs) + self.date_format = date_format + + def render(self, obj): + value = self.get_value(obj) + + if not value: + return '' + + return date(value, self.date_format) + + +class DateTimeColumn(DateColumn): + + def __init__( + self, + *args, + date_format: str = "Y-m-d H:i", + **kwargs, + ): + super().__init__(*args, date_format=date_format, **kwargs) + + +class TemplatedColumn(BaseColumn): + + def __init__( + self, + *args, + template_name: str, + context: dict | None = None, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.template_name = template_name + self.context = context or {} + + def get_context(self, obj): + context = self.context + + context.update({ + 'column': self, + 'obj': obj, + 'table': self.table, + }) + + return context + + def render(self, obj): + context = self.get_context(obj) + + return render_to_string(self.template_name, context) + + +class LinkColumn(TemplatedColumn): + + def __init__( + self, + *args, + url: str | Callable, + text: str | Callable, + open_in_new_tab: bool = False, + css_classes: str = 'underline', + **kwargs, + ): + if 'template_name' not in kwargs: + kwargs['template_name'] = 'easy_tables/columns/link.html' + + super().__init__(*args, **kwargs) + + self.url = url + self.text = text + self.open_in_new_tab = open_in_new_tab + self.css_classes = css_classes + + def get_url(self, obj): + if is_callable(self.url): + return self.url(obj) + + return self.url + + def get_text(self, obj): + if is_callable(self.text): + return self.text(obj) + + return self.text + + def get_context(self, obj): + context = super().get_context(obj) + + context.update({ + 'url': self.get_url(obj), + 'text': self.get_text(obj), + 'open_in_new_tab': self.open_in_new_tab, + 'css_classes': self.css_classes, + }) + + return context + + +class ButtonColumn(LinkColumn): + + def __init__( + self, + button_class: str = 'btn light:btn-primary dark:btn-outline', + show_check_function: Callable = None, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + + self.css_classes = button_class + self.show_check_function = show_check_function + + def render(self, obj): + if self.show_check_function and not self.show_check_function(obj): + return '' + + return super().render(obj) diff --git a/humitifier-server/src/main/easy_tables/tables.py b/humitifier-server/src/main/easy_tables/tables.py new file mode 100644 index 0000000..2d7e017 --- /dev/null +++ b/humitifier-server/src/main/easy_tables/tables.py @@ -0,0 +1,181 @@ +import copy +from collections import OrderedDict +from inspect import isclass +from typing import Iterable + +from django.template.loader import render_to_string + +from main.easy_tables.columns import BaseColumn, ModelValueColumn + + +class TableOptions: + + def __init__(self, meta): + self.template = getattr(meta, 'template', 'easy_tables/table.html') + self.model = getattr(meta, 'model', None) + self.columns = getattr(meta, 'columns', []) + self.meta = meta + self.table = None + self.column_type_overrides = getattr(meta, 'column_type_overrides', {}) + self.column_breakpoint_overrides = getattr(meta, 'column_breakpoint_overrides', {}) + self.no_data_message = getattr( + meta, + 'no_data_message', + 'No data available. Please check your filters.' + ) + self.no_data_message_wild_wasteland = getattr( + meta, + 'no_data_message_wild_wasteland', + "Oops! Looks like we ran out of data. Time to panic!" + ) + + def contribute_to_class(self, cls): + self.table = cls + columns = [] + + if self.model: + for field in self.model._meta.fields: + if field.name in self.columns: + + if field.name in self.column_type_overrides: + column = self.column_type_overrides[field.name] + + if isclass(column): + instance = column(header=field.verbose_name, value_attr=field.name) + columns.append((field.name, instance)) + else: + if column.header is None: + column.header = field.verbose_name + + if hasattr(column, 'value_attr') and column.value_attr is None: + column.value_attr = field.name + + columns.append((field.name, column)) + else: + columns.append((field.name, ModelValueColumn(header=field.verbose_name, value_attr=field.name))) + + return columns + + +class DeclarativeColumnsMetaclass(type): + def __new__(mcs, name, bases, attrs): + + # Ignore base class. + if not bases: + return super().__new__(mcs, name, bases, attrs) + + # Collect columns from current class and remove them from attrs. + columns = [ + (key, value) + for key, value in list(attrs.items()) + if isinstance(value, BaseColumn) + ] + for key, _ in columns: + attrs.pop(key) + + # Create the new class. + new_class = super().__new__(mcs, name, bases, attrs) + + # Handle Meta options + meta = attrs.get('Meta', None) + if meta: + meta = TableOptions(meta) + new_class._meta = meta + extra_columns = meta.contribute_to_class(new_class) + if extra_columns: + column_keys = [column[0] for column in columns] + for key, column in extra_columns: + # Do not override columns declared explicitly in the class. + if key not in column_keys: + columns.append((key, column)) + + # Walk through the MRO. + declared_columns = OrderedDict(columns) + for base in reversed(new_class.__mro__): + if hasattr(base, 'declared_columns'): + declared_columns.update(base.declared_columns) + for key, value in base.__dict__.items(): + if isinstance(value, BaseColumn): + declared_columns[key] = value + + # If columns are declared in the options class, reorder the columns + # based on that order. + if new_class._meta.columns: + new_order = OrderedDict() + for column_name in new_class._meta.columns: + if column_name not in declared_columns: + raise ValueError(f'Column {column_name} not found in declared columns.') + + new_order[column_name] = declared_columns[column_name] + + declared_columns = new_order + + for column, bp in new_class._meta.column_breakpoint_overrides.items(): + if column in declared_columns: + declared_columns[column].hide_breakpoint = bp + + new_class.declared_columns = declared_columns + + for column in declared_columns.values(): + column.contribute_to_class(new_class) + + return new_class + + +class BaseTable(metaclass=DeclarativeColumnsMetaclass): + + def __init__( + self, + *args, + data: Iterable | None = None, + paginator=None, + page_object=None, + ordering: str | None = None, + ordering_fields: dict[str, str] | None =None, + page_sizes: list[int] | None = None, + filterset=None, + request=None, + **kwargs + ): + self.columns : dict[str, BaseColumn] = copy.deepcopy(self.declared_columns) + self.data = data + self.paginator = paginator + self.page_object = page_object + self.ordering = ordering + self.ordering_fields = ordering_fields + self.page_sizes = page_sizes + self.filterset = filterset + self.request = request + + super().__init__(*args, **kwargs) + + def render(self, obj_list = None, paginator = None): + data = obj_list or self.data + paginator = paginator or self.paginator + + rows = [] + for obj in data: + row = [] + for column in self.columns.values(): + row.append((column, column.render(obj))) + rows.append(row) + + no_data_message = self._meta.no_data_message + if self.request and self.request.user.wild_wasteland_mode: + no_data_message = self._meta.no_data_message_wild_wasteland + + context = { + 'table': self, + 'columns': self.columns, + 'rows': rows, + 'has_pagination': paginator is not None, + 'paginator': paginator, + 'page_obj': self.page_object, + 'page_sizes': self.page_sizes, + 'ordering': self.ordering, + 'ordering_fields': self.ordering_fields, + 'filterset': self.filterset, + 'no_data_message': no_data_message, + } + + return render_to_string(self._meta.template, context) diff --git a/humitifier-server/src/main/filters.py b/humitifier-server/src/main/filters.py new file mode 100644 index 0000000..7d95401 --- /dev/null +++ b/humitifier-server/src/main/filters.py @@ -0,0 +1,56 @@ +import django_filters +from django.forms import Form + +from main.models import AccessProfile, User + + +class FiltersForm(Form): + template_name = 'base/page_parts/filters_form_template.html' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + for field_name, field in self.fields.items(): + self.fields[field_name].widget.attrs['placeholder'] = field.label + + +class UserFilters(django_filters.FilterSet): + class Meta: + model = User + fields = ['is_active', 'is_local_account', 'access_profile'] + form = FiltersForm + + is_active = django_filters.ChoiceFilter( + empty_label="Is active", + field_name="is_active", + choices=[ + (True, "Yes"), + (False, "No"), + ] + ) + + is_local_account = django_filters.ChoiceFilter( + empty_label="Is local account", + field_name="is_local_account", + choices=[ + (True, "Yes"), + (False, "No"), + ] + ) + + access_profile = django_filters.ModelChoiceFilter( + field_name="access_profile", + queryset=AccessProfile.objects.all(), + empty_label="Access profile", + ) + +class AccessProfileFilters(django_filters.FilterSet): + class Meta: + model = AccessProfile + fields = ['departments'] + form = FiltersForm + + departments = django_filters.CharFilter( + field_name="departments", + lookup_expr='icontains', + ) \ No newline at end of file diff --git a/humitifier-server/src/main/forms.py b/humitifier-server/src/main/forms.py index 9c0f5ff..6e07969 100644 --- a/humitifier-server/src/main/forms.py +++ b/humitifier-server/src/main/forms.py @@ -1,6 +1,160 @@ +from django import forms from django.forms.renderers import TemplatesSetting +from django.utils.safestring import mark_safe + +from hosts.filters import _get_choices +from main.models import AccessProfile, User class CustomFormRenderer(TemplatesSetting): - form_template_name = 'base/forms/form_template.html' + form_template_name = "base/forms/form_template.html" field_template_name = "base/forms/field_template.html" + + +class UserForm(forms.ModelForm): + class Meta: + model = User + fields = [ + "username", + "email", + "first_name", + "last_name", + "wild_wasteland_mode", + "default_home", + "is_superuser", + "access_profiles", + ] + widgets = { + "access_profiles": forms.CheckboxSelectMultiple, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance and not self.instance.is_local_account: + self.fields['username'].disabled = True + self.fields['email'].disabled = True + self.fields['first_name'].disabled = True + self.fields['last_name'].disabled = True + + def clean_username(self): + if self.instance and not self.instance.is_local_account: + return self.instance.username + return self.cleaned_data['username'] + + def clean_email(self): + if self.instance and not self.instance.is_local_account: + return self.instance.email + return self.cleaned_data['email'] + + def clean_first_name(self): + if self.instance and not self.instance.is_local_account: + return self.instance.first_name + return self.cleaned_data['first_name'] + + def clean_last_name(self): + if self.instance and not self.instance.is_local_account: + return self.instance.last_name + return self.cleaned_data['last_name'] + + +class UserProfileForm(forms.ModelForm): + class Meta: + model = User + fields = [ + "wild_wasteland_mode", + "default_home", + ] + widgets = { + "wild_wasteland_mode": forms.CheckboxInput, + } + + +class CreateSolisUserForm(forms.ModelForm): + class Meta: + model = User + fields = [ + "username", + "is_superuser", + "access_profiles", + ] + widgets = { + "access_profiles": forms.CheckboxSelectMultiple, + } + + +class SetPasswordForm(forms.ModelForm): + class Meta: + model = User + fields = [] + + new_password = forms.CharField( + widget=forms.PasswordInput, + label='New Password', + ) + password_confirm = forms.CharField( + widget=forms.PasswordInput, + label='Confirm Password', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + + self.fields['new_password'].widget.attrs['placeholder'] = 'hunter2' + self.fields['password_confirm'].widget.attrs['placeholder'] = 'hunter2' + + def clean(self): + cleaned_data = super().clean() + new_password = cleaned_data.get('new_password') + password_confirm = cleaned_data.get('password_confirm') + + if new_password != password_confirm: + raise forms.ValidationError("Passwords do not match") + + return cleaned_data + + def save(self, commit=True): + self.instance.set_password(self.cleaned_data['new_password']) + return super().save(commit=commit) + + +class DepartmentsWidget(forms.CheckboxSelectMultiple): + + def format_value(self, value): + if value is None: + return [] + return value.split(',') + + def optgroups(self, name, value, attrs=None): + # First, reset the choices with the current options from the DB + # This cannot be done in __init__ because the choices change over time + self.choices = _get_choices('department') + print(value) + + # Then, add any values that are not in the choices (but are in the + # value) to the options, as they might be departments that were removed + # from the dataset at some point + for val in value: + if val not in dict(self.choices): + label = val.strip('"') + # Mark the label as gray to indicate it's not in the dataset + label = mark_safe(f"{label}") + self.choices.append((val, label)) + + self.choices = sorted(self.choices, key=lambda x: x[0]) + + return super().optgroups(name, value, attrs) + + +class AccessProfileForm(forms.ModelForm): + class Meta: + model = AccessProfile + fields = [ + "name", + "description", + "departments", + ] + widgets = { + "departments": DepartmentsWidget, + } \ No newline at end of file diff --git a/humitifier-server/src/main/menus.py b/humitifier-server/src/main/menus.py index f050b55..2b91fec 100644 --- a/humitifier-server/src/main/menus.py +++ b/humitifier-server/src/main/menus.py @@ -8,6 +8,7 @@ reverse("main:dashboard"), weight=1, icon="icons/dashboard.html", + check=lambda request: request.user.is_authenticated, ) ) @@ -19,6 +20,7 @@ weight=20, icon="icons/users.html", separator=True, + check=lambda request: request.user.is_superuser, ) ) @@ -28,20 +30,11 @@ "Access profiles", reverse("main:access_profiles"), weight=21, + check=lambda request: request.user.is_superuser, icon="icons/shield.html", ) ) -Menu.add_item( - "main", - MenuItem( - "OAuth2 Applications", - reverse("main:oauth_applications"), - weight=21, - icon="icons/api.html", - ) -) - Menu.add_item( "main", MenuItem( diff --git a/humitifier-server/src/main/migrations/0002_accessprofile_user_default_home_and_more.py b/humitifier-server/src/main/migrations/0002_accessprofile_user_default_home_and_more.py new file mode 100644 index 0000000..e04b2bd --- /dev/null +++ b/humitifier-server/src/main/migrations/0002_accessprofile_user_default_home_and_more.py @@ -0,0 +1,66 @@ +# Generated by Django 5.1.2 on 2024-11-04 15:34 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("main", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="AccessProfile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200)), + ("description", models.TextField(blank=True)), + ( + "departments", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=200), size=None + ), + ), + ], + ), + migrations.AddField( + model_name="user", + name="default_home", + field=models.CharField( + choices=[("dashboard", "Dashboard"), ("hosts", "Hosts")], + default="hosts", + max_length=20, + ), + ), + migrations.AddField( + model_name="user", + name="is_local_account", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="user", + name="wild_wasteland_mode", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="user", + name="access_profile", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="main.accessprofile", + ), + ), + ] diff --git a/humitifier-server/src/main/migrations/0003_remove_user_access_profile_user_access_profiles.py b/humitifier-server/src/main/migrations/0003_remove_user_access_profile_user_access_profiles.py new file mode 100644 index 0000000..0ef23d5 --- /dev/null +++ b/humitifier-server/src/main/migrations/0003_remove_user_access_profile_user_access_profiles.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2024-11-06 12:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("main", "0002_accessprofile_user_default_home_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="access_profile", + ), + migrations.AddField( + model_name="user", + name="access_profiles", + field=models.ManyToManyField( + blank=True, related_name="users", to="main.accessprofile" + ), + ), + ] diff --git a/humitifier-server/src/main/migrations/0004_alter_user_default_home_and_more.py b/humitifier-server/src/main/migrations/0004_alter_user_default_home_and_more.py new file mode 100644 index 0000000..1d24cd5 --- /dev/null +++ b/humitifier-server/src/main/migrations/0004_alter_user_default_home_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.2 on 2024-11-08 14:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("main", "0003_remove_user_access_profile_user_access_profiles"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="default_home", + field=models.CharField( + choices=[("dashboard", "Dashboard"), ("hosts", "Hosts")], + default="hosts", + help_text="The default page to redirect to after login.", + max_length=20, + ), + ), + migrations.AlterField( + model_name="user", + name="wild_wasteland_mode", + field=models.BooleanField( + default=False, + help_text="Enables easter eggs, non-default as it's less professional.", + ), + ), + ] diff --git a/humitifier-server/src/main/models.py b/humitifier-server/src/main/models.py index c8e51ca..7dfdcda 100644 --- a/humitifier-server/src/main/models.py +++ b/humitifier-server/src/main/models.py @@ -1,7 +1,83 @@ from django.contrib.auth.models import AbstractUser +from django.contrib.postgres.fields import ArrayField from django.db import models -# Create your models here. + +class HomeOptions(models.TextChoices): + DASHBOARD = 'dashboard', 'Dashboard' + HOSTS = 'hosts', 'Hosts' + class User(AbstractUser): - pass \ No newline at end of file + + is_local_account = models.BooleanField(default=True) + + wild_wasteland_mode = models.BooleanField( + default=False, + help_text="Enables easter eggs, non-default as it's less professional." + ) + + default_home = models.CharField( + max_length=20, + choices=HomeOptions.choices, + default=HomeOptions.HOSTS, + help_text="The default page to redirect to after login." + ) + + access_profiles = models.ManyToManyField( + 'AccessProfile', + blank=True, + related_name='users', + ) + + @property + def departments_for_filter(self): + departments = set() + + for access_profile in self.access_profiles.all(): + departments.update(access_profile.departments_for_filter) + + return departments + + +class AccessProfile(models.Model): + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + + departments = ArrayField( + models.CharField(max_length=200), + ) + + def get_departments_display(self): + stripped = [department.strip('"') for department in self.departments] + return ', '.join(stripped) + + @property + def departments_for_filter(self): + departments = [] + + for department in self.departments: + departments.append(department) + + # Explanation for the following: the department field on hosts + # is generated from JSON data. For some reason, this adds + # quotes around the department name. + # The following code is some safeguarding to make sure that + # the filter works as expected. (As 'self.departments' is human + # input) + + # If we have a department with quotes, we need to add an option + # without quotes as well, just to be sure. + if department.endswith('"'): + departments.append(department[1:-1]) + # And the other way around too + else: + departments.append(f'"{department}"') + + return departments + + def __str__(self): + return self.name + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/humitifier-server/src/main/static/main/css/tailwind.css b/humitifier-server/src/main/static/main/css/tailwind.css index ce746a2..2c845af 100644 --- a/humitifier-server/src/main/static/main/css/tailwind.css +++ b/humitifier-server/src/main/static/main/css/tailwind.css @@ -568,6 +568,10 @@ video { min-height: calc(100vh - 3.5rem); } +.top-header { + top: 3.5rem; +} + .section { --tw-bg-opacity: 1; background-color: rgb(250 250 250 / var(--tw-bg-opacity)); @@ -641,6 +645,21 @@ video { border-color: rgb(82 82 91 / var(--tw-border-opacity)); } +.input:disabled { + cursor: not-allowed; + --tw-bg-opacity: 1; + background-color: rgb(228 228 231 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(113 113 122 / var(--tw-text-opacity)); +} + +.input:disabled:where(.dark, .dark *) { + --tw-bg-opacity: 1; + background-color: rgb(39 39 42 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(161 161 170 / var(--tw-text-opacity)); +} + .btn { border-radius: 0.25rem; border-width: 1px; @@ -693,6 +712,81 @@ video { background-color: rgb(63 63 70 / var(--tw-bg-opacity)); } +.btn-primary { + --tw-bg-opacity: 1; + background-color: rgb(255 205 0 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +.btn-primary:hover { + --tw-bg-opacity: 1; + background-color: rgb(234 179 8 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +.btn-primary:where(.dark, .dark *) { + --tw-bg-opacity: 1; + background-color: rgb(234 179 8 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +.btn-primary:hover:where(.dark, .dark *) { + --tw-border-opacity: 1; + border-color: rgb(250 204 21 / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: rgb(250 204 21 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +.btn-danger { + --tw-border-opacity: 1; + border-color: rgb(239 68 68 / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: rgb(239 68 68 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.btn-danger:hover { + --tw-border-opacity: 1; + border-color: rgb(220 38 38 / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: rgb(220 38 38 / var(--tw-bg-opacity)); +} + +.btn-danger:where(.dark, .dark *) { + --tw-border-opacity: 1; + border-color: rgb(185 28 28 / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: rgb(185 28 28 / var(--tw-bg-opacity)); +} + +.btn-danger:hover:where(.dark, .dark *) { + --tw-border-opacity: 1; + border-color: rgb(239 68 68 / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: rgb(239 68 68 / var(--tw-bg-opacity)); +} + +.text-blue { + --tw-text-opacity: 1; + color: rgb(37 99 235 / var(--tw-text-opacity)); +} + +.text-blue:where(.dark, .dark *) { + --tw-text-opacity: 1; + color: rgb(147 197 253 / var(--tw-text-opacity)); +} + +.text-orange { + --tw-text-opacity: 1; + color: rgb(249 115 22 / var(--tw-text-opacity)); +} + .visible { visibility: visible; } @@ -721,6 +815,10 @@ video { left: 0px; } +.right-2 { + right: 0.5rem; +} + .top-0 { top: 0px; } @@ -737,6 +835,10 @@ video { z-index: 40; } +.z-50 { + z-index: 50; +} + .col-span-2 { grid-column: span 2 / span 2; } @@ -758,6 +860,14 @@ video { margin-bottom: 0px; } +.mb-10 { + margin-bottom: 2.5rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + .mb-3 { margin-bottom: 0.75rem; } @@ -766,8 +876,12 @@ video { margin-bottom: 1rem; } -.mb-6 { - margin-bottom: 1.5rem; +.mb-7 { + margin-bottom: 1.75rem; +} + +.ml-2 { + margin-left: 0.5rem; } .ml-auto { @@ -786,18 +900,30 @@ video { margin-top: 0.25rem; } +.mt-3 { + margin-top: 0.75rem; +} + .mt-4 { margin-top: 1rem; } -.mt-3 { - margin-top: 0.75rem; +.mt-5 { + margin-top: 1.25rem; } .mt-6 { margin-top: 1.5rem; } +.mt-7 { + margin-top: 1.75rem; +} + +.mt-8 { + margin-top: 2rem; +} + .block { display: block; } @@ -840,10 +966,22 @@ video { height: 0.5rem; } +.h-24 { + height: 6rem; +} + +.h-dvh { + height: 100dvh; +} + .h-full { height: 100%; } +.h-px { + height: 1px; +} + .h-screen { height: 100vh; } @@ -860,6 +998,10 @@ video { width: 50%; } +.w-24 { + width: 6rem; +} + .w-64 { width: 16rem; } @@ -872,10 +1014,26 @@ video { width: calc(100vw - 3.5rem); } +.w-auto { + width: auto; +} + +.w-dvw { + width: 100dvw; +} + .w-full { width: 100%; } +.min-w-40 { + min-width: 10rem; +} + +.max-w-xl { + max-width: 36rem; +} + .flex-shrink { flex-shrink: 1; } @@ -964,10 +1122,18 @@ video { align-items: stretch; } +.justify-start { + justify-content: flex-start; +} + .justify-end { justify-content: flex-end; } +.justify-center { + justify-content: center; +} + .justify-between { justify-content: space-between; } @@ -984,6 +1150,10 @@ video { gap: 1rem; } +.gap-8 { + gap: 2rem; +} + .gap-x-2 { -moz-column-gap: 0.5rem; column-gap: 0.5rem; @@ -1019,6 +1189,11 @@ video { border-radius: 0.5rem; } +.rounded-b { + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + .rounded-tl { border-top-left-radius: 0.25rem; } @@ -1055,6 +1230,11 @@ video { border-color: rgb(253 224 71 / var(--tw-border-opacity)); } +.border-b-gray-300 { + --tw-border-opacity: 1; + border-bottom-color: rgb(212 212 216 / var(--tw-border-opacity)); +} + .bg-black { --tw-bg-opacity: 1; background-color: rgb(0 0 0 / var(--tw-bg-opacity)); @@ -1075,6 +1255,16 @@ video { background-color: rgb(228 228 231 / var(--tw-bg-opacity)); } +.bg-gray-50 { + --tw-bg-opacity: 1; + background-color: rgb(250 250 250 / var(--tw-bg-opacity)); +} + +.bg-gray-600 { + --tw-bg-opacity: 1; + background-color: rgb(82 82 91 / var(--tw-bg-opacity)); +} + .bg-green-400 { --tw-bg-opacity: 1; background-color: rgb(74 222 128 / var(--tw-bg-opacity)); @@ -1139,6 +1329,10 @@ video { padding: 1.25rem; } +.p-8 { + padding: 2rem; +} + .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; @@ -1199,6 +1393,16 @@ video { padding-bottom: 1.25rem; } +.py-7 { + padding-top: 1.75rem; + padding-bottom: 1.75rem; +} + +.py-8 { + padding-top: 2rem; + padding-bottom: 2rem; +} + .pb-0 { padding-bottom: 0px; } @@ -1215,6 +1419,10 @@ video { padding-right: 1.75rem; } +.pt-5 { + padding-top: 1.25rem; +} + .text-left { text-align: left; } @@ -1237,6 +1445,11 @@ video { line-height: 2.25rem; } +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} + .text-sm { font-size: 0.875rem; line-height: 1.25rem; @@ -1284,6 +1497,11 @@ video { color: rgb(113 113 122 / var(--tw-text-opacity)); } +.text-gray-600 { + --tw-text-opacity: 1; + color: rgb(82 82 91 / var(--tw-text-opacity)); +} + .text-gray-700 { --tw-text-opacity: 1; color: rgb(63 63 70 / var(--tw-text-opacity)); @@ -1339,6 +1557,10 @@ video { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.filter { + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} + .transition { transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; @@ -1375,6 +1597,22 @@ html:not(.dark) .light\:btn-primary:hover { color: rgb(0 0 0 / var(--tw-text-opacity)); } +html:not(.dark) .light\:btn-primary:where(.dark, .dark *) { + --tw-bg-opacity: 1; + background-color: rgb(234 179 8 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +html:not(.dark) .light\:btn-primary:hover:where(.dark, .dark *) { + --tw-border-opacity: 1; + border-color: rgb(250 204 21 / var(--tw-border-opacity)); + --tw-bg-opacity: 1; + background-color: rgb(250 204 21 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + .dark\:btn-outline:where(.dark, .dark *) { border-width: 1px; --tw-border-opacity: 1; @@ -1404,6 +1642,11 @@ html:not(.dark) .light\:btn-primary:hover { margin-bottom: 0px; } +.hover\:bg-gray-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(212 212 216 / var(--tw-bg-opacity)); +} + .hover\:bg-neutral-100:hover { --tw-bg-opacity: 1; background-color: rgb(245 245 245 / var(--tw-bg-opacity)); @@ -1419,6 +1662,12 @@ html:not(.dark) .light\:btn-primary:hover { outline-offset: 2px; } +@media (min-width: 640px) { + .sm\:table-cell { + display: table-cell; + } +} + @media (min-width: 768px) { .md\:ml-72 { margin-left: 18rem; @@ -1428,6 +1677,10 @@ html:not(.dark) .light\:btn-primary:hover { display: block; } + .md\:table-cell { + display: table-cell; + } + .md\:hidden { display: none; } @@ -1451,10 +1704,34 @@ html:not(.dark) .light\:btn-primary:hover { display: table-cell; } + .lg\:h-auto { + height: auto; + } + + .lg\:w-96 { + width: 24rem; + } + + .lg\:w-\[60rem\] { + width: 60rem; + } + .lg\:columns-2 { -moz-columns: 2; columns: 2; } + + .lg\:flex-row { + flex-direction: row; + } + + .lg\:justify-center { + justify-content: center; + } + + .lg\:justify-between { + justify-content: space-between; + } } @media (min-width: 1280px) { @@ -1500,6 +1777,10 @@ html:not(.dark) .light\:btn-primary:hover { } @media (min-width: 2000px) { + .ultrawide\:table-cell { + display: table-cell; + } + .ultrawide\:columns-4 { -moz-columns: 4; columns: 4; @@ -1519,6 +1800,11 @@ html:not(.dark) .light\:btn-primary:hover { border-color: rgb(63 63 70 / var(--tw-border-opacity)); } +.dark\:border-b-gray-800:where(.dark, .dark *) { + --tw-border-opacity: 1; + border-bottom-color: rgb(39 39 42 / var(--tw-border-opacity)); +} + .dark\:bg-blue-300:where(.dark, .dark *) { --tw-bg-opacity: 1; background-color: rgb(147 197 253 / var(--tw-bg-opacity)); @@ -1529,6 +1815,11 @@ html:not(.dark) .light\:btn-primary:hover { background-color: rgb(30 64 175 / var(--tw-bg-opacity)); } +.dark\:bg-gray-500:where(.dark, .dark *) { + --tw-bg-opacity: 1; + background-color: rgb(113 113 122 / var(--tw-bg-opacity)); +} + .dark\:bg-gray-700:where(.dark, .dark *) { --tw-bg-opacity: 1; background-color: rgb(63 63 70 / var(--tw-bg-opacity)); diff --git a/humitifier-server/src/main/static/main/css/tailwind.input.css b/humitifier-server/src/main/static/main/css/tailwind.input.css index 73cdd63..9655522 100644 --- a/humitifier-server/src/main/static/main/css/tailwind.input.css +++ b/humitifier-server/src/main/static/main/css/tailwind.input.css @@ -12,6 +12,11 @@ @apply min-h-[calc(100vh-3.5rem)]; } + .top-header + { + @apply top-[3.5rem]; + } + .section { @apply bg p-7 pt-5 rounded; } @@ -24,6 +29,10 @@ @apply w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 text-sm text-gray-700 dark:text-gray-50 placeholder-gray-400 border border-gray-200 dark:border-gray-700 rounded focus-visible:outline-none focus-visible:border-gray-400 dark:focus-visible:border-gray-600 transition duration-150 ease-in-out; } + .input:disabled { + @apply bg-gray-200 dark:bg-gray-800 text-gray-500 dark:text-gray-400 cursor-not-allowed; + } + .btn { @apply px-3 py-2 font-bold rounded transition duration-150 ease-in-out border border-primary; } @@ -41,7 +50,11 @@ } .btn-primary { - @apply bg-primary text-black hover:bg-yellow-500 hover:text-black; + @apply bg-primary text-black hover:bg-yellow-500 hover:text-black dark:bg-yellow-500 dark:text-black dark:hover:bg-yellow-400 dark:hover:border-yellow-400 dark:hover:text-black; + } + + .btn-danger { + @apply bg-red-500 text-white hover:bg-red-600 border-red-500 hover:border-red-600 dark:border-red-700 dark:bg-red-700 dark:hover:bg-red-500 dark:hover:border-red-500; } .text-blue { diff --git a/humitifier-server/src/main/static/main/img/favicon.ico b/humitifier-server/src/main/static/main/img/favicon.ico index 807024c..6f2db54 100644 Binary files a/humitifier-server/src/main/static/main/img/favicon.ico and b/humitifier-server/src/main/static/main/img/favicon.ico differ diff --git a/humitifier-server/src/main/static/main/img/wild_wasteland_favicon.ico b/humitifier-server/src/main/static/main/img/wild_wasteland_favicon.ico new file mode 100644 index 0000000..807024c Binary files /dev/null and b/humitifier-server/src/main/static/main/img/wild_wasteland_favicon.ico differ diff --git a/humitifier-server/src/main/tables.py b/humitifier-server/src/main/tables.py new file mode 100644 index 0000000..5697130 --- /dev/null +++ b/humitifier-server/src/main/tables.py @@ -0,0 +1,109 @@ +from django.urls import reverse +from django.utils.safestring import mark_safe + +from main.easy_tables import BooleanColumn, BaseTable, ButtonColumn, \ + CompoundColumn, \ + MethodColumn, ValueColumn +from main.models import AccessProfile, User + + +class UsersTable(BaseTable): + class Meta: + model = User + columns = [ + 'username', + 'full_name', + 'email', + 'access_profiles', + 'is_active', + 'is_local_account', + 'actions', + ] + column_type_overrides = { + 'is_active': BooleanColumn, + 'is_local_account': BooleanColumn( + yes_no_values={True: 'Local', False: 'Solis', None: 'Unknown'} + ), + } + column_breakpoint_overrides = { + 'full_name': 'lg', + 'email': 'md', + 'is_active': 'xl', + 'is_local_account': '2xl', + 'access_profiles': 'sm', + } + no_data_message = "No users found. Please check your filters." + no_data_message_wild_wasteland = "It's lonely in here... Where did all the users go?" + + full_name = ValueColumn( + "Name", + value_attr='get_full_name' + ) + + actions = CompoundColumn( + 'Actions', + columns=[ + ButtonColumn( + text='Edit', + button_class='btn light:btn-primary dark:btn-outline mr-2', + url=lambda obj: reverse('main:edit_user', args=[obj.pk]), + ), + ButtonColumn( + text=lambda obj: 'Disable' if obj.is_active else 'Enable', + button_class='btn btn-danger mr-2', + url=lambda obj: reverse('main:deactivate_user', args=[obj.pk]), + ), + ButtonColumn( + text="Change Password", + button_class='btn btn-outline', + url=lambda obj: reverse('main:user_change_password', args=[obj.pk]), + show_check_function=lambda obj: obj.is_local_account, + ), + ] + ) + + # M2M fields are not supported by easy_tables, so let's use a MethodColumn + access_profiles = MethodColumn( + 'Access Profiles', + method_name='get_access_profiles' + ) + + @staticmethod + def get_access_profiles(obj: User): + if obj.is_superuser: + return mark_safe("SuperuserNone + + + + + {% block page_title %}Humitifier{% endblock %} + + + {% if layout.wild_wasteland %} + + {% else %} + + {% endif %} + + + {% if debug %} + + {% endif %} + {% block head %}{% endblock %} + + + +{% block body %}{% endblock %} + + \ No newline at end of file diff --git a/humitifier-server/src/main/templates/base/base_page_template.html b/humitifier-server/src/main/templates/base/base_page_template.html index c4a75a7..ad42c73 100644 --- a/humitifier-server/src/main/templates/base/base_page_template.html +++ b/humitifier-server/src/main/templates/base/base_page_template.html @@ -1,42 +1,14 @@ +{% extends 'base/base_html_template.html' %} {% load static %} - - - - - - {% block page_title %}Humitifier{% endblock %} - - - {% if debug %} - - {% endif %} - {% block head %}{% endblock %} - - - +{% block body %} + {% include 'base/page_parts/sidebar.html' %} -{% include 'base/page_parts/sidebar.html' %} +
+ {% include 'base/page_parts/header.html' %} -
- {% include 'base/page_parts/header.html' %} + {% block content %} + {% endblock %} - {% block content %} - {% endblock %} - -
- - \ No newline at end of file +
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/base/forms/field_template.html b/humitifier-server/src/main/templates/base/forms/field_template.html index 7c27fb9..2a917db 100644 --- a/humitifier-server/src/main/templates/base/forms/field_template.html +++ b/humitifier-server/src/main/templates/base/forms/field_template.html @@ -1,8 +1,23 @@ -
- {% if field.label %} - +{% load is_checkbox %} + +
+ {% if field.field.widget|is_checkbox %} + +
+ {{ field.help_text }} +
+ {% else %} + {% if field.label %} + + {% endif %} +
+ {{ field.help_text }} +
+
+ {{ field }} +
{% endif %} -
- {{ field }} -
-
+
\ No newline at end of file diff --git a/humitifier-server/src/main/templates/base/forms/form_template.html b/humitifier-server/src/main/templates/base/forms/form_template.html index fb740fc..d07f3a8 100644 --- a/humitifier-server/src/main/templates/base/forms/form_template.html +++ b/humitifier-server/src/main/templates/base/forms/form_template.html @@ -1,10 +1,24 @@ -{{ errors }} +{% if errors %} +
+ {{ errors }} +
+{% endif %} + {% if errors and not fields %}
{% for field in hidden_fields %}{{ field }}{% endfor %}
{% endif %} {% for field, errors in fields %} {{ field.as_field_group }} + {% if errors %} +
+ {% for error in errors %} + {{ error }} + {% endfor %} +
+ {% endif %} +
+ {% if forloop.last %} {% for field in hidden_fields %}{{ field }}{% endfor %} {% endif %} diff --git a/humitifier-server/src/main/templates/base/page_parts/header.html b/humitifier-server/src/main/templates/base/page_parts/header.html index 0741e6c..c1b3ed8 100644 --- a/humitifier-server/src/main/templates/base/page_parts/header.html +++ b/humitifier-server/src/main/templates/base/page_parts/header.html @@ -1,8 +1,9 @@
- - + +
@@ -30,7 +31,19 @@
-
- Humitifier User +
+ {{ user.get_full_name }} +
+
+ Profile + {% if user.is_local_user %} + Set password + {% endif %} +
+ {% csrf_token %} + +
+
\ No newline at end of file diff --git a/humitifier-server/src/main/templates/django/forms/widgets/checkbox.html b/humitifier-server/src/main/templates/django/forms/widgets/checkbox.html new file mode 100644 index 0000000..25859cf --- /dev/null +++ b/humitifier-server/src/main/templates/django/forms/widgets/checkbox.html @@ -0,0 +1 @@ +{% include "django/forms/widgets/input_option_field.html" %} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/django/forms/widgets/input_option.html b/humitifier-server/src/main/templates/django/forms/widgets/input_option.html new file mode 100644 index 0000000..a6a41e4 --- /dev/null +++ b/humitifier-server/src/main/templates/django/forms/widgets/input_option.html @@ -0,0 +1,8 @@ +{% if widget.wrap_label %} + +{% endif %} +{% include "django/forms/widgets/input_option_field.html" %} +{% if widget.wrap_label %} + {{ widget.label }} + +{% endif %} diff --git a/humitifier-server/src/main/templates/django/forms/widgets/input_option_field.html b/humitifier-server/src/main/templates/django/forms/widgets/input_option_field.html new file mode 100644 index 0000000..a1b5bd2 --- /dev/null +++ b/humitifier-server/src/main/templates/django/forms/widgets/input_option_field.html @@ -0,0 +1,9 @@ + diff --git a/humitifier-server/src/main/templates/django/forms/widgets/textarea.html b/humitifier-server/src/main/templates/django/forms/widgets/textarea.html new file mode 100644 index 0000000..db4cff5 --- /dev/null +++ b/humitifier-server/src/main/templates/django/forms/widgets/textarea.html @@ -0,0 +1,5 @@ + diff --git a/humitifier-server/src/main/templates/easy_tables/columns/link.html b/humitifier-server/src/main/templates/easy_tables/columns/link.html new file mode 100644 index 0000000..0a791e2 --- /dev/null +++ b/humitifier-server/src/main/templates/easy_tables/columns/link.html @@ -0,0 +1,5 @@ +{{ text }} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/easy_tables/table.html b/humitifier-server/src/main/templates/easy_tables/table.html new file mode 100644 index 0000000..7260915 --- /dev/null +++ b/humitifier-server/src/main/templates/easy_tables/table.html @@ -0,0 +1,41 @@ +{% if has_pagination %} +
+ {% include 'base/page_parts/paginator_top.html' %} +
+{% endif %} + + + + + {% for column in columns.values %} + + {% endfor %} + + + + {% for row in rows %} + + {% for column, content in row %} + + {% endfor %} + + {% endfor %} + +
+ {{ column.header|default:"unknown"|capfirst }} +
+ {{ content }} +
+ +{# Not in the table as it's a pain to deal with colspan and responsiveness #} +{% if rows|length == 0 %} +
+ {{ no_data_message }} +
+{% endif %} + +{% if has_pagination %} +
+ {% include 'base/page_parts/paginator_bottom.html' %} +
+{% endif %} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/icons/document.html b/humitifier-server/src/main/templates/icons/document.html new file mode 100644 index 0000000..407ab74 --- /dev/null +++ b/humitifier-server/src/main/templates/icons/document.html @@ -0,0 +1,3 @@ + + + diff --git a/humitifier-server/src/main/templates/main/accessprofile_confirm_delete.html b/humitifier-server/src/main/templates/main/accessprofile_confirm_delete.html new file mode 100644 index 0000000..2ead50b --- /dev/null +++ b/humitifier-server/src/main/templates/main/accessprofile_confirm_delete.html @@ -0,0 +1,24 @@ +{% extends 'base/base_page_template.html' %} + +{% block content %} +
+

Delete access profile

+

+ Are you sure you want to delete the access profile "{{ object }}"? + All users currently using this access profile will have their access + granted through this profile revoked. +

+
+ {% csrf_token %} + {{ form }} +
+ + Cancel + + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/main/accessprofile_form.html b/humitifier-server/src/main/templates/main/accessprofile_form.html new file mode 100644 index 0000000..addc919 --- /dev/null +++ b/humitifier-server/src/main/templates/main/accessprofile_form.html @@ -0,0 +1,25 @@ +{% extends 'base/base_page_template.html' %} + +{% block content %} +
+ {% if object %} +

Edit access profile

+ {% else %} +

Create new access profile

+ {% endif %} + +
+ {% csrf_token %} + {{ form }} + +
+ + Cancel + + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/main/accessprofile_list.html b/humitifier-server/src/main/templates/main/accessprofile_list.html new file mode 100644 index 0000000..285cc61 --- /dev/null +++ b/humitifier-server/src/main/templates/main/accessprofile_list.html @@ -0,0 +1,24 @@ +{% extends "base/base_page_template.html" %} + +{% load humanize %} +{% load param_replace %} + +{% block page_title %}Access Profiles | {{ block.super }}{% endblock %} + +{% block content %} +
+
+

Access Profiles

+ +
+ + {{ table.render }} +
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/main/user_deactivate.html b/humitifier-server/src/main/templates/main/user_deactivate.html new file mode 100644 index 0000000..ba914e5 --- /dev/null +++ b/humitifier-server/src/main/templates/main/user_deactivate.html @@ -0,0 +1,29 @@ +{% extends 'base/base_page_template.html' %} + +{% block content %} +
+ {% if object.is_active %} +

Enable user

+

+ Are you sure you want to enable user '{{ object }}'? +

+ {% else %} +

Disable user

+

+ Are you sure you want to disable user '{{ object }}'? +

+ {% endif %} +
+ {% csrf_token %} + {{ form }} +
+ + Cancel + + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/main/user_form.html b/humitifier-server/src/main/templates/main/user_form.html new file mode 100644 index 0000000..1c88085 --- /dev/null +++ b/humitifier-server/src/main/templates/main/user_form.html @@ -0,0 +1,39 @@ +{% extends 'base/base_page_template.html' %} + +{% block content %} +
+ {% if object %} +

Edit {% if is_solis %}solis-{% endif %}user

+ {% else %} +

Create new {% if is_solis %}solis-{% endif %}user

+ {% endif %} + + {% if object and not object.is_local_account %} +
+ {% include 'icons/warning.html' %} + You are editing a Solis user. Some fields are provided by OIDC and cannot be changed. +
+ {% endif %} + +
+ {% csrf_token %} + {{ form }} + + {% if object %} +
Date joined
+
{{ form_user.date_joined }}
+
Last login
+
{{ form_user.last_login }}
+ {% endif %} + +
+ + Cancel + + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/main/user_list.html b/humitifier-server/src/main/templates/main/user_list.html new file mode 100644 index 0000000..57dc30a --- /dev/null +++ b/humitifier-server/src/main/templates/main/user_list.html @@ -0,0 +1,32 @@ +{% extends "base/base_page_template.html" %} + +{% load humanize %} +{% load param_replace %} + +{% block page_title %}Users | {{ block.super }}{% endblock %} + +{% block content %} +
+
+

Users

+ +
+ + {{ table.render }} +
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/main/user_list_parts/user.html b/humitifier-server/src/main/templates/main/user_list_parts/user.html new file mode 100644 index 0000000..730c854 --- /dev/null +++ b/humitifier-server/src/main/templates/main/user_list_parts/user.html @@ -0,0 +1,23 @@ +{% load strip_quotes %} + + + {{ user.get_full_name }} + + + {{ user.email }} + + + {{ user.get_access_profile_display }} + + + {{ user.is_active|yesno:"Yes,No" }} {# TODO: icon? #} + + {% if layout.oidc_enabled %} + + {{ user.is_local_account|yesno:"Local,Solis" }} + + {% endif %} + + Meep! + + diff --git a/humitifier-server/src/main/templates/main/user_profile.html b/humitifier-server/src/main/templates/main/user_profile.html new file mode 100644 index 0000000..4c3974e --- /dev/null +++ b/humitifier-server/src/main/templates/main/user_profile.html @@ -0,0 +1,48 @@ +{% extends 'base/base_page_template.html' %} + +{% block content %} +
+

User profile

+
+
Username
+
{{ form_user.username }}
+ +
Email
+
{{ form_user.email }}
+ +
First name
+
{{ form_user.first_name }}
+ +
Last name
+
{{ form_user.last_name }}
+ +
Is superuser
+
{{ form_user.is_superuser|yesno:'Yes,No' }}
+ +
Access profiles
+
+ {% if form_user.access_profiles.exists %} + {% for profile in form_user.access_profiles.all %} + {{ profile }}
+ {% endfor %} + {% else %} + No profiles + {% endif %} +
+ +
Date joined
+
{{ form_user.date_joined }}
+
+ +

Settings

+
+ {% csrf_token %} + {{ form }} +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/main/user_set_password_form.html b/humitifier-server/src/main/templates/main/user_set_password_form.html new file mode 100644 index 0000000..0522f9e --- /dev/null +++ b/humitifier-server/src/main/templates/main/user_set_password_form.html @@ -0,0 +1,24 @@ +{% extends 'base/base_page_template.html' %} + +{% block content %} +
+

Set password

+ +

+ Set a new password for user '{{ object }}'. +

+ +
+ {% csrf_token %} + {{ form }} +
+ + Cancel + + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/main/templates/registration/login.html b/humitifier-server/src/main/templates/registration/login.html new file mode 100644 index 0000000..39c366f --- /dev/null +++ b/humitifier-server/src/main/templates/registration/login.html @@ -0,0 +1,77 @@ +{% extends 'base/base_html_template.html' %} +{% load static %} + +{% block page_title %}Login {{ block.super }}{% endblock %} + + +{% block body %} +
+ +
+
+ UU Logo +
+
+ Humitifier +
+
+ Humanities-IT Services CMDB +
+
+
+
+

Login

+ + {% if user.is_authenticated %} +

+ Your account doesn't have access to this page. To proceed, + please login with an account that has access. +

+ {% endif %} + + {% if form.errors %} +

Your username and password didn't match. Please try again.

+ {% endif %} + +
+ {% csrf_token %} +
+ +
+ {{ form.username }} +
+
+
+ +
+ {{ form.password }} +
+
+ + +
+ + {% if layout.oidc_enabled %} +
+
+
or
+
+
+ + Login with Solis-ID + + {% endif %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/humitifier-server/src/main/templatetags/is_checkbox.py b/humitifier-server/src/main/templatetags/is_checkbox.py new file mode 100644 index 0000000..521cc71 --- /dev/null +++ b/humitifier-server/src/main/templatetags/is_checkbox.py @@ -0,0 +1,8 @@ +from django import template +from django.forms.fields import CheckboxInput + +register = template.Library() + +@register.filter(name='is_checkbox') +def is_checkbox(value): + return isinstance(value, CheckboxInput) \ No newline at end of file diff --git a/humitifier-server/src/main/urls.py b/humitifier-server/src/main/urls.py index 66cb867..9a113a6 100644 --- a/humitifier-server/src/main/urls.py +++ b/humitifier-server/src/main/urls.py @@ -1,15 +1,36 @@ +from django.contrib.auth.views import LoginView from django.urls import path -from .views import AccessProfilesView, DashboardView, HomeRedirectView, \ - OAuthApplicationsView, \ - UsersView +from .views import AccessProfilesView, CreateAccessProfileView, \ + CreateSolisUserView, CreateUserView, \ + DashboardView, \ + DeActivateUserView, \ + DeleteAccessProfileView, EditAccessProfileView, EditUserView, \ + HomeRedirectView, \ + SetPasswordView, UserProfileView, UsersView app_name = 'main' urlpatterns = [ path("", HomeRedirectView.as_view(), name="home"), path("dashboard/", DashboardView.as_view(), name="dashboard"), + path("user_profile/", UserProfileView.as_view(), name="user_profile"), path("users/", UsersView.as_view(), name="users"), + path("users/create/", CreateUserView.as_view(), name="create_user"), + path("users/create-solis/", CreateSolisUserView.as_view(), + name="create_solis_user"), + path("users//edit/", EditUserView.as_view(), + name="edit_user"), + path("users//change-password/", SetPasswordView.as_view(), + name="user_change_password"), + path("users//deactivate/", DeActivateUserView.as_view(), name="deactivate_user"), path("access-profiles/", AccessProfilesView.as_view(), name="access_profiles"), - path("oauth-applications/", OAuthApplicationsView.as_view(), name="oauth_applications"), + path("access-profiles/create/", CreateAccessProfileView.as_view(), + name="create_access_profile"), + path("access-profiles//edit/", EditAccessProfileView.as_view(), + name="edit_access_profile"), + path("access-profiles//delete/", DeleteAccessProfileView.as_view(), + name="delete_access_profile"), + + path("login", LoginView.as_view(), name="login"), ] diff --git a/humitifier-server/src/main/views.py b/humitifier-server/src/main/views.py index f7c247b..1d2b4a0 100644 --- a/humitifier-server/src/main/views.py +++ b/humitifier-server/src/main/views.py @@ -1,11 +1,88 @@ +from urllib.parse import urlparse + +from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin +from django.contrib.auth.views import redirect_to_login from django.db.models import Count +from django.forms import Form +from django.http import HttpResponseRedirect +from django.shortcuts import resolve_url from django.urls import reverse -from django.views.generic import ListView, RedirectView, TemplateView +from django.views.generic import DeleteView, ListView, RedirectView, \ + TemplateView, \ + UpdateView +from django.views.generic.detail import BaseDetailView, \ + SingleObjectTemplateResponseMixin +from django.views.generic.edit import CreateView, FormMixin +from rest_framework.reverse import reverse_lazy from hosts.filters import AlertFilters -from hosts.models import Alert, AlertType, Host +from hosts.models import Alert, Host +from main.filters import AccessProfileFilters, UserFilters +from main.forms import AccessProfileForm, CreateSolisUserForm, SetPasswordForm, \ + UserForm, \ + UserProfileForm +from main.models import AccessProfile, User +from main.tables import AccessProfilesTable, UsersTable +### +### Mixins +### + +class SuperuserRequiredMixin(AccessMixin): + """ + Require users to be superusers to access the view. + """ + + def dispatch(self, request, *args, **kwargs): + """Call the appropriate handler if the user is a superuser""" + if not request.user.is_superuser: + return self.handle_no_permission() + + return super().dispatch(request, *args, **kwargs) + + def handle_no_permission(self): + """Redirect to the login page if the user is not a superuser""" + path = self.request.build_absolute_uri() + resolved_login_url = resolve_url(self.get_login_url()) + # If the login url is the same scheme and net location then use the + # path as the "next" url. + login_scheme, login_netloc = urlparse(resolved_login_url)[:2] + current_scheme, current_netloc = urlparse(path)[:2] + if (not login_scheme or login_scheme == current_scheme) and ( + not login_netloc or login_netloc == current_netloc + ): + path = self.request.get_full_path() + return redirect_to_login( + path, + resolved_login_url, + self.get_redirect_field_name(), + ) + +class TableMixin: + table_class = None + + def get_table_class(self): + return self.table_class + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + table_class = self.get_table_class() + + context['table'] = table_class( + data=context['object_list'], + paginator=context['paginator'], + page_object=context['page_obj'], + filterset=context['filterset'], + ordering=context['ordering'], + ordering_fields=context['ordering_fields'], + page_sizes=context['page_sizes'], + request=self.request, + ) + + return context + ### ### Generic views ### @@ -70,14 +147,14 @@ def get_ordering_fields(self): ### Page views ### -class HomeRedirectView(RedirectView): +class HomeRedirectView(LoginRequiredMixin, RedirectView): def get_redirect_url(self, *args, **kwargs): # TODO: user preferences return reverse('hosts:list') -class DashboardView(FilteredListView): +class DashboardView(LoginRequiredMixin, FilteredListView): model = Alert filterset_class = AlertFilters paginate_by = 20 @@ -126,11 +203,190 @@ def get_context_data(self, **kwargs): return context -class UsersView(TemplateView): - template_name = 'main/not_implemented.html' +class UsersView( + LoginRequiredMixin, + SuperuserRequiredMixin, + TableMixin, + FilteredListView +): + model = User + table_class = UsersTable + filterset_class = UserFilters + paginate_by = 50 + template_name = 'main/user_list.html' + ordering = 'username' + ordering_fields = { + 'username': 'Username', + 'email': 'Email', + 'first_name': 'First name', + 'last_name': 'Last name', + 'access_profile': 'Access profile', + } -class AccessProfilesView(TemplateView): - template_name = 'main/not_implemented.html' + def get_queryset(self): + qs = super().get_queryset() + + return qs.prefetch_related('access_profiles') + + +class DeActivateUserView( + LoginRequiredMixin, + SuperuserRequiredMixin, + SingleObjectTemplateResponseMixin, + FormMixin, + BaseDetailView +): + model = User + form_class = Form + template_name = 'main/user_deactivate.html' + + def get_success_url(self): + return reverse('main:users') + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.get_form() + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) + + def form_valid(self, form): + success_url = self.get_success_url() + + self.object.is_active = not self.object.is_active + self.object.save() + + return HttpResponseRedirect(success_url) + +class CreateUserView( + LoginRequiredMixin, + SuperuserRequiredMixin, + CreateView +): + model = User + form_class = UserForm + success_url = reverse_lazy('main:users') + context_object_name = 'form_user' # needed to keep the view from + # overriding the user object in the context + + +class CreateSolisUserView( + LoginRequiredMixin, + SuperuserRequiredMixin, + CreateView +): + model = User + form_class = CreateSolisUserForm + success_url = reverse_lazy('main:users') + context_object_name = 'form_user' # needed to keep the view from + # overriding the user object in the context + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['is_solis'] = True + + return context + + def form_valid(self, form): + form.instance.is_local_account = False + return super().form_valid(form) + + +class EditUserView( + LoginRequiredMixin, + SuperuserRequiredMixin, + UpdateView +): + model = User + form_class = UserForm + success_url = reverse_lazy('main:users') + context_object_name = 'form_user' # needed to keep the view from + # overriding the user object in the context + + +class UserProfileView( + LoginRequiredMixin, + UpdateView +): + model = User + form_class = UserProfileForm + success_url = reverse_lazy('main:user_profile') + template_name = 'main/user_profile.html' + context_object_name = 'form_user' # needed to keep the view from + # overriding the user object in the context + + def get_object(self, queryset=None): + return self.request.user + + +class SetPasswordView( + LoginRequiredMixin, + UpdateView, +): + model = User + form_class = SetPasswordForm + template_name = 'main/user_set_password_form.html' + success_url = reverse_lazy('main:users') + context_object_name = 'form_user' # needed to keep the view from + # overriding the user object in the context + + def dispatch(self, request, *args, **kwargs): + requested_user = self.get_object() + + # Only local accounts can have their password changed + if not requested_user.is_local_account: + return self.handle_no_permission() + + # Only superusers can change the password of other users + if not request.user.is_superuser and request.user != requested_user: + return self.handle_no_permission() + + return super().dispatch(request, *args, **kwargs) + + + +class AccessProfilesView( + LoginRequiredMixin, + SuperuserRequiredMixin, + TableMixin, + FilteredListView +): + model = AccessProfile + table_class = AccessProfilesTable + filterset_class = AccessProfileFilters + paginate_by = 50 + template_name = 'main/accessprofile_list.html' + ordering = 'name' + ordering_fields = { + 'name': 'name', + } -class OAuthApplicationsView(TemplateView): - template_name = 'main/not_implemented.html' \ No newline at end of file +class CreateAccessProfileView( + LoginRequiredMixin, + SuperuserRequiredMixin, + CreateView +): + model = AccessProfile + form_class = AccessProfileForm + success_url = reverse_lazy('main:access_profiles') + + +class EditAccessProfileView( + LoginRequiredMixin, + SuperuserRequiredMixin, + UpdateView +): + model = AccessProfile + form_class = AccessProfileForm + success_url = reverse_lazy('main:access_profiles') + + +class DeleteAccessProfileView( + LoginRequiredMixin, + SuperuserRequiredMixin, + DeleteView +): + model = AccessProfile + success_url = reverse_lazy('main:access_profiles') diff --git a/humitifier-server/tailwind.config.js b/humitifier-server/tailwind.config.js index 872ebca..177819c 100644 --- a/humitifier-server/tailwind.config.js +++ b/humitifier-server/tailwind.config.js @@ -3,6 +3,16 @@ const colors = require('tailwindcss/colors') /** @type {import('tailwindcss').Config} */ module.exports = { content: ["./src/*/templates/**/*.html"], + safelist: [ + 'sm:table-cell', + 'md:table-cell', + 'lg:table-cell', + 'xl:table-cell', + '2xl:table-cell', + 'ultrawide:table-cell', + 'btn-danger', + 'btn-primary', + ], darkMode: ['selector'], theme: { container: {