diff --git a/.circleci/config.yml b/.circleci/config.yml index af450fad..1bb49bb3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,7 +30,7 @@ commands: default: atat_test container_env: type: string - default: -e PGHOST=postgres -e REDIS_URI=redis://redis:6379 + default: -e PGHOST=postgres -e REDIS_HOST=redis:6379 steps: - run: name: Set up temporary docker network @@ -172,7 +172,7 @@ jobs: command: | docker run \ -e PGHOST=postgres \ - -e REDIS_URI=redis://redis:6379 \ + -e REDIS_HOST=redis:6379 \ --network atat \ atat:builder \ /bin/sh -c "pipenv install --dev && /bin/sh script/cibuild" @@ -195,7 +195,7 @@ jobs: docker run -d \ -e DISABLE_CRL_CHECK=true \ -e PGHOST=postgres \ - -e REDIS_URI=redis://redis:6379 \ + -e REDIS_HOST=redis:6379 \ -p 8000:8000 \ --network atat \ --name test-atat \ @@ -253,7 +253,7 @@ jobs: command: | docker run \ -e PGHOST=postgres \ - -e REDIS_URI=redis://redis:6379 \ + -e REDIS_HOST=redis:6379 \ --network atat \ atat:builder \ /bin/sh -c "pipenv install --dev && /bin/sh script/sync-crls && pipenv run pytest --no-cov tests/check_crl_parse.py" @@ -293,6 +293,11 @@ workflows: - integration-tests: requires: - docker-build + filters: + branches: + only: + - staging + - master - deploy-staging: requires: - test diff --git a/.gitignore b/.gitignore index 19f4acc5..d8e2290d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ static/buildinfo.* log/* config/dev.ini +.env* # CRLs /crl diff --git a/.secrets.baseline b/.secrets.baseline index 07353d5a..05921baa 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$|^.*pgsslrootcert.yml$", "lines": null }, - "generated_at": "2019-11-26T21:33:43Z", + "generated_at": "2019-12-06T21:22:07Z", "plugins_used": [ { "base64_limit": 4.5, @@ -98,7 +98,7 @@ "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", "is_secret": false, "is_verified": false, - "line_number": 21, + "line_number": 29, "type": "Secret Keyword" } ], @@ -161,7 +161,7 @@ "hashed_secret": "e4f14805dfd1e6af030359090c535e149e6b4207", "is_secret": false, "is_verified": false, - "line_number": 32, + "line_number": 41, "type": "Hex High Entropy String" } ], diff --git a/Dockerfile b/Dockerfile index e11a8723..744c9739 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,6 +87,7 @@ COPY --from=builder /install/translations.yaml . COPY --from=builder /install/script/seed_roles.py ./script/seed_roles.py COPY --from=builder /install/script/sync-crls ./script/sync-crls COPY --from=builder /install/static/ ./static/ +COPY --from=builder /install/fixtures/ ./fixtures COPY --from=builder /install/uwsgi.ini . COPY --from=builder /usr/local/bin/uwsgi /usr/local/bin/uwsgi diff --git a/Pipfile b/Pipfile index 6456afb1..edc8bbfc 100644 --- a/Pipfile +++ b/Pipfile @@ -29,6 +29,7 @@ azure-mgmt-subscription = "*" azure-graphrbac = "*" msrestazure = "*" azure-mgmt-authorization = "*" +azure-mgmt-managementgroups = "*" [dev-packages] bandit = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 9c5233bb..80f6fc96 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6d2ab855267daac877ae7464de9dba5b62b7d89288992f87d8fc6ff0c0d2520f" + "sha256": "c2b19c436646705ea3bf4df8c35c2833083f048da37fc619e66f7236153607c5" }, "pipfile-spec": 6, "requires": { @@ -39,11 +39,11 @@ }, "apache-libcloud": { "hashes": [ - "sha256:201751f738109f25d58dcdfb5804e17216e0dc8f68b522e9e26ac16e0b9ff2ea", - "sha256:40215db1bd489d17dc1abfdb289d7f035313c7297b6a7462c79d8287cbbeae91" + "sha256:9bc5cd5c32151bb7a04a7c7de0be9b4a4b8271e348ac91dd79eaaeeae627115f", + "sha256:fcc165f2cc2db9a379c6d3a17b3beb9081bb64ba5c0bf7bbb58da864810092f0" ], "index": "pypi", - "version": "==2.6.0" + "version": "==2.6.1" }, "azure-common": { "hashes": [ @@ -68,6 +68,14 @@ "index": "pypi", "version": "==0.60.0" }, + "azure-mgmt-managementgroups": { + "hashes": [ + "sha256:3d5237947458dc94b4a392141174b1c1258d26611241ee104e9006d1d798f682", + "sha256:8194ee6274df865eccd1ed9d385ea625aeba9b8058b9e4fdf547f5207271a775" + ], + "index": "pypi", + "version": "==0.2.0" + }, "azure-mgmt-subscription": { "hashes": [ "sha256:504b4c42ba859070c3c50637ec07ca36aca600e613fcccaa398db22822fe21f1", @@ -117,10 +125,10 @@ }, "certifi": { "hashes": [ - "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", - "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" + "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", + "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" ], - "version": "==2019.9.11" + "version": "==2019.11.28" }, "cffi": { "hashes": [ @@ -248,10 +256,10 @@ }, "importlib-metadata": { "hashes": [ - "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", - "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" + "sha256:b044f07694ef14a6683b097ba56bd081dbc7cdc7c7fe46011e499dfecc082f21", + "sha256:e6ac600a142cf2db707b1998382cc7fc3b02befb7273876e01b8ad10b9652742" ], - "version": "==0.23" + "version": "==1.1.0" }, "isodate": { "hashes": [ @@ -330,10 +338,10 @@ }, "more-itertools": { "hashes": [ - "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", - "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" + "sha256:53ff73f186307d9c8ef17a9600309154a6ae27f25579e80af4db8f047ba14bc2", + "sha256:a0ea684c39bc4315ba7aae406596ef191fd84f873d2d2751f84d64e81a7a2d45" ], - "version": "==7.2.0" + "version": "==8.0.0" }, "msrest": { "hashes": [ @@ -422,11 +430,11 @@ }, "pyopenssl": { "hashes": [ - "sha256:aeca66338f6de19d1aa46ed634c3b9ae519a64b458f8468aec688e7e3c20f200", - "sha256:c727930ad54b10fc157015014b666f2d8b41f70c0d03e83ab67624fd3dd5d1e6" + "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504", + "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507" ], "index": "pypi", - "version": "==19.0.0" + "version": "==19.1.0" }, "python-dateutil": { "hashes": [ @@ -459,22 +467,20 @@ }, "pyyaml": { "hashes": [ - "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", - "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", - "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", - "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", - "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", - "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", - "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", - "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", - "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", - "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", - "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", - "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", - "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8" + "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc", + "sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803", + "sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc", + "sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15", + "sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075", + "sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd", + "sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31", + "sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f", + "sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c", + "sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04", + "sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4" ], "index": "pypi", - "version": "==5.1.2" + "version": "==5.2" }, "redis": { "hashes": [ @@ -650,10 +656,10 @@ }, "certifi": { "hashes": [ - "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", - "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" + "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", + "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" ], - "version": "==2019.9.11" + "version": "==2019.11.28" }, "chardet": { "hashes": [ @@ -785,10 +791,10 @@ }, "importlib-metadata": { "hashes": [ - "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", - "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" + "sha256:b044f07694ef14a6683b097ba56bd081dbc7cdc7c7fe46011e499dfecc082f21", + "sha256:e6ac600a142cf2db707b1998382cc7fc3b02befb7273876e01b8ad10b9652742" ], - "version": "==0.23" + "version": "==1.1.0" }, "ipdb": { "hashes": [ @@ -799,11 +805,11 @@ }, "ipython": { "hashes": [ - "sha256:dfd303b270b7b5232b3d08bd30ec6fd685d8a58cabd54055e3d69d8f029f7280", - "sha256:ed7ebe1cba899c1c3ccad6f7f1c2d2369464cc77dba8eebc65e2043e19cda995" + "sha256:c66c7e27239855828a764b1e8fc72c24a6f4498a2637572094a78c5551fb9d51", + "sha256:f186b01b36609e0c5d0de27c7ef8e80c990c70478f8c880863004b3489a9030e" ], "index": "pypi", - "version": "==7.9.0" + "version": "==7.10.1" }, "ipython-genutils": { "hashes": [ @@ -908,30 +914,30 @@ }, "more-itertools": { "hashes": [ - "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", - "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" + "sha256:53ff73f186307d9c8ef17a9600309154a6ae27f25579e80af4db8f047ba14bc2", + "sha256:a0ea684c39bc4315ba7aae406596ef191fd84f873d2d2751f84d64e81a7a2d45" ], - "version": "==7.2.0" + "version": "==8.0.0" }, "mypy": { "hashes": [ - "sha256:1521c186a3d200c399bd5573c828ea2db1362af7209b2adb1bb8532cea2fb36f", - "sha256:31a046ab040a84a0fc38bc93694876398e62bc9f35eca8ccbf6418b7297f4c00", - "sha256:3b1a411909c84b2ae9b8283b58b48541654b918e8513c20a400bb946aa9111ae", - "sha256:48c8bc99380575deb39f5d3400ebb6a8a1cb5cc669bbba4d3bb30f904e0a0e7d", - "sha256:540c9caa57a22d0d5d3c69047cc9dd0094d49782603eb03069821b41f9e970e9", - "sha256:672e418425d957e276c291930a3921b4a6413204f53fe7c37cad7bc57b9a3391", - "sha256:6ed3b9b3fdc7193ea7aca6f3c20549b377a56f28769783a8f27191903a54170f", - "sha256:9371290aa2cad5ad133e4cdc43892778efd13293406f7340b9ffe99d5ec7c1d9", - "sha256:ace6ac1d0f87d4072f05b5468a084a45b4eda970e4d26704f201e06d47ab2990", - "sha256:b428f883d2b3fe1d052c630642cc6afddd07d5cd7873da948644508be3b9d4a7", - "sha256:d5bf0e6ec8ba346a2cf35cb55bf4adfddbc6b6576fcc9e10863daa523e418dbb", - "sha256:d7574e283f83c08501607586b3167728c58e8442947e027d2d4c7dcd6d82f453", - "sha256:dc889c84241a857c263a2b1cd1121507db7d5b5f5e87e77147097230f374d10b", - "sha256:f4748697b349f373002656bf32fede706a0e713d67bfdcf04edf39b1f61d46eb" + "sha256:02d9bdd3398b636723ecb6c5cfe9773025a9ab7f34612c1cde5c7f2292e2d768", + "sha256:088f758a50af31cf8b42688118077292370c90c89232c783ba7979f39ea16646", + "sha256:28e9fbc96d13397a7ddb7fad7b14f373f91b5cff538e0772e77c270468df083c", + "sha256:30e123b24931f02c5d99307406658ac8f9cd6746f0d45a3dcac2fe5fbdd60939", + "sha256:3294821b5840d51a3cd7a2bb63b40fc3f901f6a3cfb3c6046570749c4c7ef279", + "sha256:41696a7d912ce16fdc7c141d87e8db5144d4be664a0c699a2b417d393994b0c2", + "sha256:4f42675fa278f3913340bb8c3371d191319704437758d7c4a8440346c293ecb2", + "sha256:54d205ccce6ed930a8a2ccf48404896d456e8b87812e491cb907a355b1a9c640", + "sha256:6992133c95a2847d309b4b0c899d7054adc60481df6f6b52bb7dee3d5fd157f7", + "sha256:6ecbd0e8e371333027abca0922b0c2c632a5b4739a0c61ffbd0733391e39144c", + "sha256:83fa87f556e60782c0fc3df1b37b7b4a840314ba1ac27f3e1a1e10cb37c89c17", + "sha256:c87ac7233c629f305602f563db07f5221950fe34fe30af072ac838fa85395f78", + "sha256:de9ec8dba773b78c49e7bec9a35c9b6fc5235682ad1fc2105752ae7c22f4b931", + "sha256:f385a0accf353ca1bca4bbf473b9d83ed18d923fdb809d3a70a385da23e25b6a" ], "index": "pypi", - "version": "==0.740" + "version": "==0.750" }, "mypy-extensions": { "hashes": [ @@ -961,10 +967,10 @@ }, "pbr": { "hashes": [ - "sha256:2c8e420cd4ed4cec4e7999ee47409e876af575d4c35a45840d59e8b5f3155ab8", - "sha256:b32c8ccaac7b1a20c0ce00ce317642e6cf231cf038f9875e0280e28af5bf7ac9" + "sha256:139d2625547dbfa5fb0b81daebb39601c478c21956dc57e2e07b74450a8c506b", + "sha256:61aa52a0f18b71c5cc58232d2cf8f8d09cd67fcad60b742a60124cb8d6951488" ], - "version": "==5.4.3" + "version": "==5.4.4" }, "pexpect": { "hashes": [ @@ -983,18 +989,17 @@ }, "pluggy": { "hashes": [ - "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", - "sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34" + "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", + "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], - "version": "==0.13.0" + "version": "==0.13.1" }, "prompt-toolkit": { "hashes": [ - "sha256:46642344ce457641f28fc9d1c9ca939b63dadf8df128b86f1b9860e59c73a5e4", - "sha256:e7f8af9e3d70f514373bf41aa51bc33af12a6db3f71461ea47fea985defb2c31", - "sha256:f15af68f66e664eaa559d4ac8a928111eebd5feda0c11738b5998045224829db" + "sha256:0278d2f51b5ceba6ea8da39f76d15684e84c996b325475f6e5720edc584326a7", + "sha256:63daee79aa8366c8f1c637f1a4876b890da5fc92a19ebd2f7080ebacb901e990" ], - "version": "==2.0.10" + "version": "==3.0.2" }, "ptyprocess": { "hashes": [ @@ -1012,10 +1017,10 @@ }, "pygments": { "hashes": [ - "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", - "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" + "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b", + "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe" ], - "version": "==2.4.2" + "version": "==2.5.2" }, "pylint": { "hashes": [ @@ -1058,11 +1063,11 @@ }, "pytest-mock": { "hashes": [ - "sha256:b3514caac35fe3f05555923eabd9546abce11571cc2ddf7d8615959d04f2c89e", - "sha256:ea502c3891599c26243a3a847ccf0b1d20556678c528f86c98e3cd6d40c5cf11" + "sha256:96a0cebc66e09930be2a15b03333d90b59584d3fb011924f81c14b50ee0afbba", + "sha256:e5381be2608e49547f5e47633c5f81241ebf6206d17ce516a7a18d5a917e3859" ], "index": "pypi", - "version": "==1.11.2" + "version": "==1.12.1" }, "pytest-watch": { "hashes": [ @@ -1080,22 +1085,20 @@ }, "pyyaml": { "hashes": [ - "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", - "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", - "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", - "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", - "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", - "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", - "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", - "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", - "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", - "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", - "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", - "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", - "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8" + "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc", + "sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803", + "sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc", + "sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15", + "sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075", + "sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd", + "sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31", + "sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f", + "sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c", + "sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04", + "sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4" ], "index": "pypi", - "version": "==5.1.2" + "version": "==5.2" }, "regex": { "hashes": [ diff --git a/README.md b/README.md index d18177d4..9c31fc19 100644 --- a/README.md +++ b/README.md @@ -168,12 +168,7 @@ Testing file uploads and downloads locally requires a few configuration options. In the flask config (`config/base.ini`, perhaps): ``` -CSP= - -AWS_REGION_NAME="" -AWS_ACCESS_KEY="" -AWS_SECRET_KEY="" -AWS_BUCKET_NAME="" +CSP=< azure | mock> AZURE_STORAGE_KEY="" AZURE_ACCOUNT_NAME="" @@ -183,7 +178,7 @@ AZURE_TO_BUCKET_NAME="" There are also some build-time configuration that are used by parcel. Add these to `.env.local`, and run `rm -r .cache/` before running `yarn build`: ``` -CLOUD_PROVIDER= +CLOUD_PROVIDER= AZURE_ACCOUNT_NAME="" AZURE_CONTAINER_NAME="" ``` @@ -223,6 +218,9 @@ To generate coverage reports for the Javascript tests: ## Configuration - `ASSETS_URL`: URL to host which serves static assets (such as a CDN). +- `AZURE_ACCOUNT_NAME`: The name for the Azure blob storage account +- `AZURE_STORAGE_KEY`: A valid secret key for the Azure blob storage account +- `AZURE_TO_BUCKET_NAME`: The Azure blob storage container name for task order uploads - `BLOB_STORAGE_URL`: URL to Azure blob storage container. - `CAC_URL`: URL for the CAC authentication route. - `CA_CHAIN`: Path to the CA chain file. @@ -238,6 +236,11 @@ To generate coverage reports for the Javascript tests: - `ENVIRONMENT`: String specifying the current environment. Acceptable values: "dev", "prod". - `LIMIT_CONCURRENT_SESSIONS`: Boolean specifying if users should be allowed only one active session at a time. - `LOG_JSON`: Boolean specifying whether app should log in a json format. +- `MAIL_PASSWORD`: String. Password for the SMTP server. +- `MAIL_PORT`: Integer. Port to use on the SMTP server. +- `MAIL_SENDER`: String. Email address to send outgoing mail from. +- `MAIL_SERVER`: The SMTP host +- `MAIL_TLS`: Boolean. Use TLS to connect to the SMTP server. - `PERMANENT_SESSION_LIFETIME`: Integer specifying how many seconds a user's session can stay valid for. https://flask.palletsprojects.com/en/1.1.x/config/#PERMANENT_SESSION_LIFETIME - `PGDATABASE`: String specifying the name of the postgres database. - `PGHOST`: String specifying the hostname of the postgres database. @@ -270,33 +273,8 @@ execute UI tests than vanilla Selenium. Ghost Inspector tests and steps can be exported to files that the Selenium IDE can import. We export these tests/steps regularly and archive them with the AT-AT codebase in the `uitests` directory. -To run the Ghost Inspector tests against a local instance of AT-AT, -you will need the following: - -- [docker](https://docs.docker.com/v17.12/install/) -- [circleci CLI tool](https://circleci.com/docs/2.0/local-cli/#installation) -- the prerequisite variable information listed [here](https://ghostinspector.com/docs/integration/circle-ci/) - -The version of our CircleCI config (2.1) is incompatible with the -`circleci` tool. First run: - -``` -circleci config process .circleci/config.yml > local-ci.yml -``` - -Then run the job: - -``` -circleci local execute -e GI_SUITE= -e GI_API_KEY= -e NGROK_TOKEN= --job integration-tests -c local-ci.yml -``` - -If the job fails and you want to re-run it, you may receive errors -about running docker containers or the network already existing. -Some version of the following should reset your local docker state: - -``` -docker container stop redis postgres test-atat; docker container rm redis postgres test-atat ; docker network rm atat -``` +For further information about Ghost Inspector and its use in AT-AT, check out [its README](./uitests/README.md) +in the `uitests` directory. ## Notes diff --git a/alembic/versions/3bd8552f1c57_add_unique_constraint_to_task_order_.py b/alembic/versions/3bd8552f1c57_add_unique_constraint_to_task_order_.py new file mode 100644 index 00000000..f1375626 --- /dev/null +++ b/alembic/versions/3bd8552f1c57_add_unique_constraint_to_task_order_.py @@ -0,0 +1,26 @@ +"""add unique constraint to task order number + +Revision ID: 3bd8552f1c57 +Revises: 802071bcd013 +Create Date: 2019-12-10 12:45:17.535973 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = '3bd8552f1c57' # pragma: allowlist secret +down_revision = '802071bcd013' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint('task_orders_number_key', 'task_orders', ['number']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('task_orders_number_key', 'task_orders', type_='unique') + # ### end Alembic commands ### diff --git a/alembic/versions/67a2151d6269_update_schema_based_on_business_logic.py b/alembic/versions/67a2151d6269_update_schema_based_on_business_logic.py new file mode 100644 index 00000000..06fd5d40 --- /dev/null +++ b/alembic/versions/67a2151d6269_update_schema_based_on_business_logic.py @@ -0,0 +1,198 @@ +"""update schema based on business logic + +Revision ID: 67a2151d6269 +Revises: 687fd43489d6 +Create Date: 2019-12-02 14:16:24.902108 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '67a2151d6269' # pragma: allowlist secret +down_revision = '687fd43489d6' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('application_invitations', 'application_role_id', + existing_type=postgresql.UUID(), + nullable=False) + op.alter_column('application_invitations', 'dod_id', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('application_invitations', 'expiration_time', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('application_invitations', 'first_name', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('application_invitations', 'inviter_id', + existing_type=postgresql.UUID(), + nullable=False) + op.alter_column('application_invitations', 'last_name', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('application_invitations', 'token', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('application_roles', 'status', + existing_type=sa.VARCHAR(length=8), + nullable=False) + op.alter_column('clins', 'end_date', + existing_type=sa.DATE(), + nullable=False) + op.alter_column('clins', 'jedi_clin_type', + existing_type=sa.VARCHAR(length=11), + nullable=False) + op.alter_column('clins', 'number', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('clins', 'obligated_amount', + existing_type=sa.NUMERIC(), + nullable=False) + op.alter_column('clins', 'start_date', + existing_type=sa.DATE(), + nullable=False) + op.alter_column('clins', 'total_amount', + existing_type=sa.NUMERIC(), + nullable=False) + op.alter_column('environment_roles', 'status', + existing_type=sa.VARCHAR(length=9), + nullable=False) + op.alter_column('portfolio_invitations', 'dod_id', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('portfolio_invitations', 'expiration_time', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False) + op.alter_column('portfolio_invitations', 'first_name', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('portfolio_invitations', 'inviter_id', + existing_type=postgresql.UUID(), + nullable=False) + op.alter_column('portfolio_invitations', 'last_name', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('portfolio_invitations', 'portfolio_role_id', + existing_type=postgresql.UUID(), + nullable=False) + op.alter_column('portfolio_invitations', 'token', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('portfolio_roles', 'status', + existing_type=sa.VARCHAR(length=8), + nullable=False) + op.alter_column('portfolios', 'defense_component', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('portfolios', 'name', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('task_orders', 'portfolio_id', + existing_type=postgresql.UUID(), + nullable=False) + op.drop_constraint('task_orders_user_id_fkey', 'task_orders', type_='foreignkey') + op.drop_column('task_orders', 'user_id') + op.alter_column('users', 'first_name', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('users', 'last_name', + existing_type=sa.VARCHAR(), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'last_name', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('users', 'first_name', + existing_type=sa.VARCHAR(), + nullable=True) + op.add_column('task_orders', sa.Column('user_id', postgresql.UUID(), autoincrement=False, nullable=True)) + op.create_foreign_key('task_orders_user_id_fkey', 'task_orders', 'users', ['user_id'], ['id']) + op.alter_column('task_orders', 'portfolio_id', + existing_type=postgresql.UUID(), + nullable=True) + op.alter_column('portfolios', 'name', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('portfolios', 'defense_component', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('portfolio_roles', 'status', + existing_type=sa.VARCHAR(length=8), + nullable=True) + op.alter_column('portfolio_invitations', 'token', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('portfolio_invitations', 'portfolio_role_id', + existing_type=postgresql.UUID(), + nullable=True) + op.alter_column('portfolio_invitations', 'last_name', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('portfolio_invitations', 'inviter_id', + existing_type=postgresql.UUID(), + nullable=True) + op.alter_column('portfolio_invitations', 'first_name', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('portfolio_invitations', 'expiration_time', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('portfolio_invitations', 'dod_id', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('environment_roles', 'status', + existing_type=sa.VARCHAR(length=9), + nullable=True) + op.alter_column('clins', 'total_amount', + existing_type=sa.NUMERIC(), + nullable=True) + op.alter_column('clins', 'start_date', + existing_type=sa.DATE(), + nullable=True) + op.alter_column('clins', 'obligated_amount', + existing_type=sa.NUMERIC(), + nullable=True) + op.alter_column('clins', 'number', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('clins', 'jedi_clin_type', + existing_type=sa.VARCHAR(length=11), + nullable=True) + op.alter_column('clins', 'end_date', + existing_type=sa.DATE(), + nullable=True) + op.alter_column('application_roles', 'status', + existing_type=sa.VARCHAR(length=8), + nullable=True) + op.alter_column('application_invitations', 'token', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('application_invitations', 'last_name', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('application_invitations', 'inviter_id', + existing_type=postgresql.UUID(), + nullable=True) + op.alter_column('application_invitations', 'first_name', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('application_invitations', 'expiration_time', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True) + op.alter_column('application_invitations', 'dod_id', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('application_invitations', 'application_role_id', + existing_type=postgresql.UUID(), + nullable=True) + # ### end Alembic commands ### diff --git a/alembic/versions/802071bcd013_remove_unneeded_portfolio_columns.py b/alembic/versions/802071bcd013_remove_unneeded_portfolio_columns.py new file mode 100644 index 00000000..adc6b824 --- /dev/null +++ b/alembic/versions/802071bcd013_remove_unneeded_portfolio_columns.py @@ -0,0 +1,40 @@ +"""Remove unneeded portfolio columns + +Revision ID: 802071bcd013 +Revises: 67a2151d6269 +Create Date: 2019-12-11 13:26:34.770480 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '802071bcd013' # pragma: allowlist secret +down_revision = '67a2151d6269' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('portfolios', 'dev_team') + op.drop_column('portfolios', 'complexity') + op.drop_column('portfolios', 'team_experience') + op.drop_column('portfolios', 'dev_team_other') + op.drop_column('portfolios', 'app_migration') + op.drop_column('portfolios', 'native_apps') + op.drop_column('portfolios', 'complexity_other') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('portfolios', sa.Column('complexity_other', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('portfolios', sa.Column('native_apps', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('portfolios', sa.Column('app_migration', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('portfolios', sa.Column('dev_team_other', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('portfolios', sa.Column('team_experience', sa.VARCHAR(), autoincrement=False, nullable=True)) + op.add_column('portfolios', sa.Column('complexity', postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=True)) + op.add_column('portfolios', sa.Column('dev_team', postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=True)) + # ### end Alembic commands ### diff --git a/atst/app.py b/atst/app.py index 39eab6ec..e9daabd6 100644 --- a/atst/app.py +++ b/atst/app.py @@ -200,46 +200,78 @@ def make_config(direct_config=None): ENV_CONFIG_FILENAME = os.path.join( os.path.dirname(__file__), "../config/", "{}.ini".format(ENV.lower()) ) - OVERRIDE_CONFIG_FILENAME = os.getenv("OVERRIDE_CONFIG_FULLPATH") + OVERRIDE_CONFIG_DIRECTORY = os.getenv("OVERRIDE_CONFIG_DIRECTORY") config = ConfigParser(allow_no_value=True) config.optionxform = str config_files = [BASE_CONFIG_FILENAME, ENV_CONFIG_FILENAME] - if OVERRIDE_CONFIG_FILENAME: - config_files.append(OVERRIDE_CONFIG_FILENAME) # ENV_CONFIG will override values in BASE_CONFIG. config.read(config_files) + if OVERRIDE_CONFIG_DIRECTORY: + apply_config_from_directory(OVERRIDE_CONFIG_DIRECTORY, config) + # Check for ENV variables as a final source of overrides - for confsetting in config.options("default"): - env_override = os.getenv(confsetting.upper()) - if env_override: - config.set("default", confsetting, env_override) + apply_config_from_environment(config) # override if a dictionary of options has been given if direct_config: config.read_dict({"default": direct_config}) # Assemble DATABASE_URI value - database_uri = ( - "postgres://" - + config.get("default", "PGUSER") - + ":" - + config.get("default", "PGPASSWORD") - + "@" - + config.get("default", "PGHOST") - + ":" - + config.get("default", "PGPORT") - + "/" - + config.get("default", "PGDATABASE") + database_uri = "postgres://{}:{}@{}:{}/{}".format( # pragma: allowlist secret + config.get("default", "PGUSER"), + config.get("default", "PGPASSWORD"), + config.get("default", "PGHOST"), + config.get("default", "PGPORT"), + config.get("default", "PGDATABASE"), ) config.set("default", "DATABASE_URI", database_uri) + # Assemble REDIS_URI value + redis_uri = "redis{}://{}:{}@{}".format( # pragma: allowlist secret + ("s" if config["default"].getboolean("REDIS_TLS") else ""), + (config.get("default", "REDIS_USER") or ""), + (config.get("default", "REDIS_PASSWORD") or ""), + config.get("default", "REDIS_HOST"), + ) + config.set("default", "REDIS_URI", redis_uri) + return map_config(config) +def apply_config_from_directory(config_dir, config, section="default"): + """ + Loop files in a directory, check if the names correspond to + known config values, and apply the file contents as the value + for that setting if they do. + """ + for confsetting in os.listdir(config_dir): + if confsetting in config.options(section): + full_path = os.path.join(config_dir, confsetting) + with open(full_path, "r") as conf_file: + config.set(section, confsetting, conf_file.read().strip()) + + return config + + +def apply_config_from_environment(config, section="default"): + """ + Loops all the configuration settins in a given section of a + config object and checks whether those settings also exist as + environment variables. If so, it applies the environment + variables value as the new configuration setting value. + """ + for confsetting in config.options(section): + env_override = os.getenv(confsetting.upper()) + if env_override: + config.set(section, confsetting, env_override) + + return config + + def make_redis(app, config): r = redis.Redis.from_url(config["REDIS_URI"]) app.redis = r diff --git a/atst/domain/csp/cloud.py b/atst/domain/csp/cloud.py index 4cd69d3c..ea48ba4e 100644 --- a/atst/domain/csp/cloud.py +++ b/atst/domain/csp/cloud.py @@ -3,6 +3,7 @@ import re from uuid import uuid4 from atst.models.user import User +from atst.models.application import Application from atst.models.environment import Environment from atst.models.environment_role import EnvironmentRole @@ -399,13 +400,14 @@ REMOTE_ROOT_ROLE_DEF_ID = "/providers/Microsoft.Authorization/roleDefinitions/00 class AzureSDKProvider(object): def __init__(self): - from azure.mgmt import subscription, authorization + from azure.mgmt import subscription, authorization, managementgroups import azure.graphrbac as graphrbac import azure.common.credentials as credentials from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD self.subscription = subscription self.authorization = authorization + self.managementgroups = managementgroups self.graphrbac = graphrbac self.credentials = credentials # may change to a JEDI cloud @@ -428,42 +430,23 @@ class AzureCloudProvider(CloudProviderInterface): def create_environment( self, auth_credentials: Dict, user: User, environment: Environment ): + # since this operation would only occur within a tenant, should we source the tenant + # via lookup from environment once we've created the portfolio csp data schema + # something like this: + # environment_tenant = environment.application.portfolio.csp_data.get('tenant_id', None) + # though we'd probably source the whole credentials for these calls from the portfolio csp + # data, as it would have to be where we store the creds for the at-at user within the portfolio tenant + # credentials = self._get_credential_obj(environment.application.portfolio.csp_data.get_creds()) credentials = self._get_credential_obj(self._root_creds) - sub_client = self.sdk.subscription.SubscriptionClient(credentials) - display_name = f"{environment.application.name}_{environment.name}_{environment.id}" # proposed format + management_group_id = "?" # management group id chained from environment + parent_id = "?" # from environment.application - billing_profile_id = "?" # something chained from environment? - sku_id = AZURE_SKU_ID - # we want to set AT-AT as an owner here - # we could potentially associate subscriptions with "management groups" per DOD component - body = self.sdk.subscription.models.ModernSubscriptionCreationParameters( - display_name, - billing_profile_id, - sku_id, - # owner= + management_group = self._create_management_group( + credentials, management_group_id, display_name, parent_id, ) - # These 2 seem like something that might be worthwhile to allow tiebacks to - # TOs filed for the environment - billing_account_name = "?" - invoice_section_name = "?" - # We may also want to create billing sections in the enrollment account - sub_creation_operation = sub_client.subscription_factory.create_subscription( - billing_account_name, invoice_section_name, body - ) - - # the resulting object from this process is a link to the new subscription - # not a subscription model, so we'll have to unpack the ID - new_sub = sub_creation_operation.result() - - subscription_id = self._extract_subscription_id(new_sub.subscription_link) - if subscription_id: - return subscription_id - else: - # troublesome error, subscription should exist at this point - # but we just don't have a valid ID - pass + return management_group def create_atat_admin_user( self, auth_credentials: Dict, csp_environment_id: str @@ -502,6 +485,82 @@ class AzureCloudProvider(CloudProviderInterface): "role_name": role_assignment_id, } + def _create_application(self, auth_credentials: Dict, application: Application): + management_group_name = str(uuid4()) # can be anything, not just uuid + display_name = application.name # Does this need to be unique? + credentials = self._get_credential_obj(auth_credentials) + parent_id = "?" # application.portfolio.csp_details.management_group_id + + return self._create_management_group( + credentials, management_group_name, display_name, parent_id, + ) + + def _create_management_group( + self, credentials, management_group_id, display_name, parent_id=None, + ): + mgmgt_group_client = self.sdk.managementgroups.ManagementGroupsAPI(credentials) + create_parent_grp_info = self.sdk.managementgroups.models.CreateParentGroupInfo( + id=parent_id + ) + create_mgmt_grp_details = self.sdk.managementgroups.models.CreateManagementGroupDetails( + parent=create_parent_grp_info + ) + mgmt_grp_create = self.sdk.managementgroups.models.CreateManagementGroupRequest( + name=management_group_id, + display_name=display_name, + details=create_mgmt_grp_details, + ) + create_request = mgmgt_group_client.management_groups.create_or_update( + management_group_id, mgmt_grp_create + ) + + # result is a synchronous wait, might need to do a poll instead to handle first mgmt group create + # since we were told it could take 10+ minutes to complete, unless this handles that polling internally + return create_request.result() + + def _create_subscription( + self, + credentials, + display_name, + billing_profile_id, + sku_id, + management_group_id, + billing_account_name, + invoice_section_name, + ): + sub_client = self.sdk.subscription.SubscriptionClient(credentials) + + billing_profile_id = "?" # where do we source this? + sku_id = AZURE_SKU_ID + # These 2 seem like something that might be worthwhile to allow tiebacks to + # TOs filed for the environment + billing_account_name = "?" # from TO? + invoice_section_name = "?" # from TO? + + body = self.sdk.subscription.models.ModernSubscriptionCreationParameters( + display_name=display_name, + billing_profile_id=billing_profile_id, + sku_id=sku_id, + management_group_id=management_group_id, + ) + + # We may also want to create billing sections in the enrollment account + sub_creation_operation = sub_client.subscription_factory.create_subscription( + billing_account_name, invoice_section_name, body + ) + + # the resulting object from this process is a link to the new subscription + # not a subscription model, so we'll have to unpack the ID + new_sub = sub_creation_operation.result() + + subscription_id = self._extract_subscription_id(new_sub.subscription_link) + if subscription_id: + return subscription_id + else: + # troublesome error, subscription should exist at this point + # but we just don't have a valid ID + pass + def _get_management_service_principal(self): # we really should be using graph.microsoft.com, but i'm getting # "expired token" errors for that diff --git a/atst/domain/csp/reports.py b/atst/domain/csp/reports.py index 2d42fede..76590720 100644 --- a/atst/domain/csp/reports.py +++ b/atst/domain/csp/reports.py @@ -1,327 +1,121 @@ -from itertools import groupby -import pendulum +from collections import defaultdict +import json from decimal import Decimal -from collections import OrderedDict -class ReportingInterface: - def monthly_totals_for_environment(environment): - """Return the monthly totals for the specified environment. +def load_fixture_data(): + with open("fixtures/fixture_spend_data.json") as json_file: + return json.load(json_file) - Data should be in the format of a dictionary with the month as the key - and the spend in that month as the value. For example: - { "01/2018": 79.85, "02/2018": 86.54 } +class MockReportingProvider: + FIXTURE_SPEND_DATA = load_fixture_data() + @classmethod + def get_portfolio_monthly_spending(cls, portfolio): """ - raise NotImplementedError() - - -class MockEnvironment: - def __init__(self, id_, env_name): - self.id = id_ - self.name = env_name - - -class MockApplication: - def __init__(self, application_name, envs): - def make_env(name): - return MockEnvironment("{}_{}".format(application_name, name), name) - - self.name = application_name - self.environments = [make_env(env_name) for env_name in envs] - - -def generate_sample_dates(_max=8): - current = pendulum.now() - sample_dates = [] - for _i in range(_max): - sample_dates.append(current.strftime("%m/%Y")) - current = current.subtract(months=1) - - reversed(sample_dates) - return sample_dates - - -class MockReportingProvider(ReportingInterface): - MOCK_PERCENT_EXPENDED_FUNDS = 0.75 - - FIXTURE_MONTHS = generate_sample_dates() - - MONTHLY_SPEND_BY_ENVIRONMENT = { - "LC04_Integ": { - FIXTURE_MONTHS[7]: 284, - FIXTURE_MONTHS[6]: 1210, - FIXTURE_MONTHS[5]: 1430, - FIXTURE_MONTHS[4]: 1366, - FIXTURE_MONTHS[3]: 1169, - FIXTURE_MONTHS[2]: 991, - FIXTURE_MONTHS[1]: 978, - FIXTURE_MONTHS[0]: 737, - }, - "LC04_PreProd": { - FIXTURE_MONTHS[7]: 812, - FIXTURE_MONTHS[6]: 1389, - FIXTURE_MONTHS[5]: 1425, - FIXTURE_MONTHS[4]: 1306, - FIXTURE_MONTHS[3]: 1112, - FIXTURE_MONTHS[2]: 936, - FIXTURE_MONTHS[1]: 921, - FIXTURE_MONTHS[0]: 694, - }, - "LC04_Prod": { - FIXTURE_MONTHS[7]: 1742, - FIXTURE_MONTHS[6]: 1716, - FIXTURE_MONTHS[5]: 1866, - FIXTURE_MONTHS[4]: 1809, - FIXTURE_MONTHS[3]: 1839, - FIXTURE_MONTHS[2]: 1633, - FIXTURE_MONTHS[1]: 1654, - FIXTURE_MONTHS[0]: 1103, - }, - "SF18_Integ": { - FIXTURE_MONTHS[5]: 1498, - FIXTURE_MONTHS[4]: 1400, - FIXTURE_MONTHS[3]: 1394, - FIXTURE_MONTHS[2]: 1171, - FIXTURE_MONTHS[1]: 1200, - FIXTURE_MONTHS[0]: 963, - }, - "SF18_PreProd": { - FIXTURE_MONTHS[5]: 1780, - FIXTURE_MONTHS[4]: 1667, - FIXTURE_MONTHS[3]: 1703, - FIXTURE_MONTHS[2]: 1474, - FIXTURE_MONTHS[1]: 1441, - FIXTURE_MONTHS[0]: 933, - }, - "SF18_Prod": { - FIXTURE_MONTHS[5]: 1686, - FIXTURE_MONTHS[4]: 1779, - FIXTURE_MONTHS[3]: 1792, - FIXTURE_MONTHS[2]: 1570, - FIXTURE_MONTHS[1]: 1539, - FIXTURE_MONTHS[0]: 986, - }, - "Canton_Prod": { - FIXTURE_MONTHS[4]: 28699, - FIXTURE_MONTHS[3]: 26766, - FIXTURE_MONTHS[2]: 22619, - FIXTURE_MONTHS[1]: 24090, - FIXTURE_MONTHS[0]: 16719, - }, - "BD04_Integ": {}, - "BD04_PreProd": { - FIXTURE_MONTHS[7]: 7019, - FIXTURE_MONTHS[6]: 3004, - FIXTURE_MONTHS[5]: 2691, - FIXTURE_MONTHS[4]: 2901, - FIXTURE_MONTHS[3]: 3463, - FIXTURE_MONTHS[2]: 3314, - FIXTURE_MONTHS[1]: 3432, - FIXTURE_MONTHS[0]: 723, - }, - "SCV18_Dev": {FIXTURE_MONTHS[1]: 9797}, - "Crown_CR Portal Dev": { - FIXTURE_MONTHS[6]: 208, - FIXTURE_MONTHS[5]: 457, - FIXTURE_MONTHS[4]: 671, - FIXTURE_MONTHS[3]: 136, - FIXTURE_MONTHS[2]: 1524, - FIXTURE_MONTHS[1]: 2077, - FIXTURE_MONTHS[0]: 1858, - }, - "Crown_CR Staging": { - FIXTURE_MONTHS[6]: 208, - FIXTURE_MONTHS[5]: 457, - FIXTURE_MONTHS[4]: 671, - FIXTURE_MONTHS[3]: 136, - FIXTURE_MONTHS[2]: 1524, - FIXTURE_MONTHS[1]: 2077, - FIXTURE_MONTHS[0]: 1858, - }, - "Crown_CR Portal Test 1": { - FIXTURE_MONTHS[2]: 806, - FIXTURE_MONTHS[1]: 1966, - FIXTURE_MONTHS[0]: 2597, - }, - "Crown_Jewels Prod": { - FIXTURE_MONTHS[2]: 806, - FIXTURE_MONTHS[1]: 1966, - FIXTURE_MONTHS[0]: 2597, - }, - "Crown_Jewels Dev": { - FIXTURE_MONTHS[6]: 145, - FIXTURE_MONTHS[5]: 719, - FIXTURE_MONTHS[4]: 1243, - FIXTURE_MONTHS[3]: 2214, - FIXTURE_MONTHS[2]: 2959, - FIXTURE_MONTHS[1]: 4151, - FIXTURE_MONTHS[0]: 4260, - }, - "NP02_Integ": {FIXTURE_MONTHS[1]: 284, FIXTURE_MONTHS[0]: 1210}, - "NP02_PreProd": {FIXTURE_MONTHS[1]: 812, FIXTURE_MONTHS[0]: 1389}, - "NP02_Prod": {FIXTURE_MONTHS[1]: 3742, FIXTURE_MONTHS[0]: 4716}, - "FM_Integ": {FIXTURE_MONTHS[1]: 1498}, - "FM_Prod": {FIXTURE_MONTHS[0]: 5686}, - } - - REPORT_FIXTURE_MAP = { - "A-Wing": { - "applications": [ - MockApplication("LC04", ["Integ", "PreProd", "Prod"]), - MockApplication("SF18", ["Integ", "PreProd", "Prod"]), - MockApplication("Canton", ["Prod"]), - MockApplication("BD04", ["Integ", "PreProd"]), - MockApplication("SCV18", ["Dev"]), - MockApplication( - "Crown", - [ - "CR Portal Dev", - "CR Staging", - "CR Portal Test 1", - "Jewels Prod", - "Jewels Dev", - ], - ), - ], - "budget": 500_000, - }, - "B-Wing": { - "applications": [ - MockApplication("NP02", ["Integ", "PreProd", "Prod"]), - MockApplication("FM", ["Integ", "Prod"]), - ], - "budget": 70000, - }, - } - - def _rollup_application_totals(self, data): - application_totals = {} - for application, environments in data.items(): - application_spend = [ - (month, spend) - for env in environments.values() - if env - for month, spend in env.items() - ] - application_totals[application] = { - month: sum([spend[1] for spend in spends]) - for month, spends in groupby(sorted(application_spend), lambda x: x[0]) - } - - return application_totals - - def _rollup_portfolio_totals(self, application_totals): - monthly_spend = [ - (month, spend) - for application in application_totals.values() - for month, spend in application.items() - ] - portfolio_totals = {} - for month, spends in groupby(sorted(monthly_spend), lambda m: m[0]): - portfolio_totals[month] = sum([spend[1] for spend in spends]) - - return portfolio_totals - - def monthly_totals_for_environment(self, environment_id): - """Return the monthly totals for the specified environment. - - Data should be in the format of a dictionary with the month as the key - and the spend in that month as the value. For example: - - { "01/2018": 79.85, "02/2018": 86.54 } - - """ - environment_monthly_totals = self.MONTHLY_SPEND_BY_ENVIRONMENT.get( - environment_id, {} - ).copy() - - environment_monthly_totals["total_spend_to_date"] = sum( - monthly_total for monthly_total in environment_monthly_totals.values() - ) - return environment_monthly_totals - - def monthly_totals(self, portfolio): - """Return month totals rolled up by environment, application, and portfolio. - - Data should returned with three top level keys, "portfolio", "applications", - and "environments". - The "applications" key will have budget data per month for each application, - The "environments" key will have budget data for each environment. - The "portfolio" key will be total monthly spending for the portfolio. - For example: - + returns an array of application and environment spending for the + portfolio. Applications and their nested environments are sorted in + alphabetical order by name. + [ { - "environments": { "X-Wing": { "Prod": { "01/2018": 75.42 } } }, - "applications": { "X-Wing": { "01/2018": 75.42 } }, - "portfolio": { "01/2018": 75.42 }, + name + this_month + last_month + total + environments [ + { + name + this_month + last_month + total + } + ] } - + ] """ - applications = portfolio.applications - if portfolio.name in self.REPORT_FIXTURE_MAP: - applications = self.REPORT_FIXTURE_MAP[portfolio.name]["applications"] - environments = { - application.name: { - env.name: self.monthly_totals_for_environment(env.id) - for env in application.environments - } - for application in applications - } - - application_totals = self._rollup_application_totals(environments) - portfolio_totals = self._rollup_portfolio_totals(application_totals) - - return { - "environments": environments, - "applications": application_totals, - "portfolio": portfolio_totals, - } - - def get_obligated_funds_by_JEDI_clin(self, portfolio): - """ - Returns a dictionary of obligated funds and spending per JEDI CLIN - { - JEDI_CLIN: { - obligated_funds, - expended_funds - } - } - """ - if portfolio.name in self.REPORT_FIXTURE_MAP: - return_dict = {} - for jedi_clin, clins in groupby( - portfolio.active_clins, lambda clin: clin.jedi_clin_type - ): - obligated_funds = sum(clin.obligated_amount for clin in clins) - return_dict[jedi_clin.value] = { - "obligated_funds": obligated_funds, - "expended_funds": ( - obligated_funds * Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS) - ), - } - return OrderedDict( - # 0 index for dict item, -1 for last digit of 4 digit CLIN, e.g. 0001 - sorted(return_dict.items(), key=lambda clin: clin[0][-1]) + if portfolio.name in cls.FIXTURE_SPEND_DATA: + applications = cls.FIXTURE_SPEND_DATA[portfolio.name]["applications"] + return sorted( + [ + cls._get_application_monthly_totals(application) + for application in applications + ], + key=lambda app: app["name"], ) - return {} + return [] - def get_expired_task_orders(self, portfolio): - return [ + @classmethod + def _get_environment_monthly_totals(cls, environment): + """ + returns a dictionary that represents spending totals for an environment e.g. + { + name + this_month + last_month + total + } + """ + return { + "name": environment["name"], + "this_month": sum(environment["spending"]["this_month"].values()), + "last_month": sum(environment["spending"]["last_month"].values()), + "total": sum(environment["spending"]["total"].values()), + } + + @classmethod + def _get_application_monthly_totals(cls, application): + """ + returns a dictionary that represents spending totals for an application + and its environments e.g. { - "id": task_order.id, - "number": task_order.number, - "period_of_performance": { - "start_date": task_order.start_date, - "end_date": task_order.end_date, - }, - "total_obligated_funds": task_order.total_obligated_funds, - "expended_funds": ( - task_order.total_obligated_funds - * Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS) - ), + name + this_month + last_month + total + environments: [ + { + name + this_month + last_month + total + } + ] } - for task_order in portfolio.task_orders - if task_order.is_expired - ] + """ + environments = sorted( + [ + cls._get_environment_monthly_totals(env) + for env in application["environments"] + ], + key=lambda env: env["name"], + ) + return { + "name": application["name"], + "this_month": sum(env["this_month"] for env in environments), + "last_month": sum(env["last_month"] for env in environments), + "total": sum(env["total"] for env in environments), + "environments": environments, + } + + @classmethod + def get_spending_by_JEDI_clin(cls, portfolio): + """ + returns an dictionary of spending per JEDI CLIN for a portfolio + { + jedi_clin: { + invoiced + estimated + }, + } + """ + if portfolio.name in cls.FIXTURE_SPEND_DATA: + CLIN_spend_dict = defaultdict(lambda: defaultdict(Decimal)) + for application in cls.FIXTURE_SPEND_DATA[portfolio.name]["applications"]: + for environment in application["environments"]: + for clin, spend in environment["spending"]["this_month"].items(): + CLIN_spend_dict[clin]["estimated"] += Decimal(spend) + for clin, spend in environment["spending"]["total"].items(): + CLIN_spend_dict[clin]["invoiced"] += Decimal(spend) + return CLIN_spend_dict + return {} diff --git a/atst/domain/environment_roles.py b/atst/domain/environment_roles.py index add92091..ef8a4b8e 100644 --- a/atst/domain/environment_roles.py +++ b/atst/domain/environment_roles.py @@ -3,9 +3,10 @@ from flask import current_app as app from atst.database import db from atst.models import ( - EnvironmentRole, - ApplicationRole, Environment, + EnvironmentRole, + Application, + ApplicationRole, ApplicationRoleStatus, ) from atst.domain.exceptions import NotFoundError @@ -105,8 +106,9 @@ class EnvironmentRoles(object): def disable(cls, environment_role_id): environment_role = EnvironmentRoles.get_by_id(environment_role_id) - credentials = environment_role.environment.csp_credentials - app.csp.cloud.disable_user(credentials, environment_role.csp_user_id) + if environment_role.csp_user_id and not environment_role.environment.is_pending: + credentials = environment_role.environment.csp_credentials + app.csp.cloud.disable_user(credentials, environment_role.csp_user_id) environment_role.status = EnvironmentRole.Status.DISABLED db.session.add(environment_role) @@ -125,3 +127,15 @@ class EnvironmentRoles(object): .one_or_none() ) return existing_env_role + + @classmethod + def for_user(cls, user_id, portfolio_id): + return ( + db.session.query(EnvironmentRole) + .join(ApplicationRole) + .join(Application) + .filter(Application.portfolio_id == portfolio_id) + .filter(ApplicationRole.application_id == Application.id) + .filter(ApplicationRole.user_id == user_id) + .all() + ) diff --git a/atst/domain/reports.py b/atst/domain/reports.py index 94f6c54e..99b229e3 100644 --- a/atst/domain/reports.py +++ b/atst/domain/reports.py @@ -1,15 +1,44 @@ from flask import current_app +from itertools import groupby class Reports: @classmethod - def monthly_totals(cls, portfolio): - return current_app.csp.reports.monthly_totals(portfolio) + def monthly_spending(cls, portfolio): + return current_app.csp.reports.get_portfolio_monthly_spending(portfolio) @classmethod def expired_task_orders(cls, portfolio): - return current_app.csp.reports.get_expired_task_orders(portfolio) + return [ + task_order for task_order in portfolio.task_orders if task_order.is_expired + ] @classmethod def obligated_funds_by_JEDI_clin(cls, portfolio): - return current_app.csp.reports.get_obligated_funds_by_JEDI_clin(portfolio) + clin_spending = current_app.csp.reports.get_spending_by_JEDI_clin(portfolio) + active_clins = portfolio.active_clins + for jedi_clin, clins in groupby( + active_clins, key=lambda clin: clin.jedi_clin_type + ): + if not clin_spending.get(jedi_clin.name): + clin_spending[jedi_clin.name] = {} + clin_spending[jedi_clin.name]["obligated"] = sum( + clin.obligated_amount for clin in clins + ) + + output = [] + for clin in clin_spending.keys(): + invoiced = clin_spending[clin].get("invoiced", 0) + estimated = clin_spending[clin].get("estimated", 0) + obligated = clin_spending[clin].get("obligated", 0) + remaining = obligated - (invoiced + estimated) + output.append( + { + "name": clin, + "invoiced": invoiced, + "estimated": estimated, + "obligated": obligated, + "remaining": remaining, + } + ) + return output diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 93521843..835590c9 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -1,9 +1,11 @@ import datetime +from sqlalchemy.exc import IntegrityError from atst.database import db from atst.models.clin import CLIN from atst.models.task_order import TaskOrder, SORT_ORDERING from . import BaseDomainClass +from .exceptions import AlreadyExistsError class TaskOrders(BaseDomainClass): @@ -11,12 +13,15 @@ class TaskOrders(BaseDomainClass): resource_name = "task_order" @classmethod - def create(cls, creator, portfolio_id, number, clins, pdf): - task_order = TaskOrder( - portfolio_id=portfolio_id, creator=creator, number=number, pdf=pdf - ) + def create(cls, portfolio_id, number, clins, pdf): + task_order = TaskOrder(portfolio_id=portfolio_id, number=number, pdf=pdf) db.session.add(task_order) - db.session.commit() + + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + raise AlreadyExistsError("task_order") TaskOrders.create_clins(task_order.id, clins) @@ -37,7 +42,12 @@ class TaskOrders(BaseDomainClass): task_order.number = number db.session.add(task_order) - db.session.commit() + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + raise AlreadyExistsError("task_order") + return task_order @classmethod @@ -66,10 +76,12 @@ class TaskOrders(BaseDomainClass): db.session.commit() @classmethod - def sort(cls, task_orders: [TaskOrder]) -> [TaskOrder]: - # Sorts a list of task orders on two keys: status (primary) and time_created (secondary) - by_time_created = sorted(task_orders, key=lambda to: to.time_created) - by_status = sorted(by_time_created, key=lambda to: SORT_ORDERING.get(to.status)) + def sort_by_status(cls, task_orders): + by_status = {status.value: [] for status in SORT_ORDERING} + + for task_order in task_orders: + by_status[task_order.display_status].append(task_order) + return by_status @classmethod diff --git a/atst/filters.py b/atst/filters.py index 48b8166c..3508f1e9 100644 --- a/atst/filters.py +++ b/atst/filters.py @@ -5,6 +5,7 @@ from flask import render_template from jinja2 import contextfilter from jinja2.exceptions import TemplateNotFound from urllib.parse import urlparse, urlunparse, parse_qs, urlencode +from decimal import DivisionByZero as DivisionByZeroException def iconSvg(name): @@ -38,6 +39,14 @@ def usPhone(number): return "+1 ({}) {} - {}".format(phone[0:3], phone[3:6], phone[6:]) +def obligatedFundingGraphWidth(values): + numerator, denominator = values + try: + return (numerator / denominator) * 100 + except DivisionByZeroException: + return 0 + + def formattedDate(value, formatter="%m/%d/%Y"): if value: return value.strftime(formatter) @@ -76,6 +85,7 @@ def register_filters(app): app.jinja_env.filters["pageWindow"] = pageWindow app.jinja_env.filters["renderAuditEvent"] = renderAuditEvent app.jinja_env.filters["withExtraParams"] = with_extra_params + app.jinja_env.filters["obligatedFundingGraphWidth"] = obligatedFundingGraphWidth @contextfilter def translateWithoutCache(context, *kwargs): diff --git a/atst/forms/data.py b/atst/forms/data.py index 7b1b4a40..5f1f9532 100644 --- a/atst/forms/data.py +++ b/atst/forms/data.py @@ -3,112 +3,14 @@ from atst.utils.localization import translate SERVICE_BRANCHES = [ - ("", "- Select -"), - ("Air Force, Department of the", "Air Force, Department of the"), - ("Army and Air Force Exchange Service", "Army and Air Force Exchange Service"), - ("Army, Department of the", "Army, Department of the"), + ("air_force", translate("forms.portfolio.defense_component.choices.air_force")), + ("army", translate("forms.portfolio.defense_component.choices.army")), ( - "Defense Advanced Research Applications Agency", - "Defense Advanced Research Applications Agency", + "marine_corps", + translate("forms.portfolio.defense_component.choices.marine_corps"), ), - ("Defense Commissary Agency", "Defense Commissary Agency"), - ("Defense Contract Audit Agency", "Defense Contract Audit Agency"), - ("Defense Contract Management Agency", "Defense Contract Management Agency"), - ("Defense Finance & Accounting Service", "Defense Finance & Accounting Service"), - ("Defense Health Agency", "Defense Health Agency"), - ("Defense Information System Agency", "Defense Information System Agency"), - ("Defense Intelligence Agency", "Defense Intelligence Agency"), - ("Defense Legal Services Agency", "Defense Legal Services Agency"), - ("Defense Logistics Agency", "Defense Logistics Agency"), - ("Defense Media Activity", "Defense Media Activity"), - ("Defense Micro Electronics Activity", "Defense Micro Electronics Activity"), - ("Defense POW-MIA Accounting Agency", "Defense POW-MIA Accounting Agency"), - ("Defense Security Cooperation Agency", "Defense Security Cooperation Agency"), - ("Defense Security Service", "Defense Security Service"), - ("Defense Technical Information Center", "Defense Technical Information Center"), - ( - "Defense Technology Security Administration", - "Defense Technology Security Administration", - ), - ("Defense Threat Reduction Agency", "Defense Threat Reduction Agency"), - ("DoD Education Activity", "DoD Education Activity"), - ("DoD Human Recourses Activity", "DoD Human Recourses Activity"), - ("DoD Inspector General", "DoD Inspector General"), - ("DoD Test Resource Management Center", "DoD Test Resource Management Center"), - ( - "Headquarters Defense Human Resource Activity ", - "Headquarters Defense Human Resource Activity ", - ), - ("Joint Staff", "Joint Staff"), - ("Missile Defense Agency", "Missile Defense Agency"), - ("National Defense University", "National Defense University"), - ( - "National Geospatial Intelligence Agency (NGA)", - "National Geospatial Intelligence Agency (NGA)", - ), - ( - "National Oceanic and Atmospheric Administration (NOAA)", - "National Oceanic and Atmospheric Administration (NOAA)", - ), - ("National Reconnaissance Office", "National Reconnaissance Office"), - ("National Reconnaissance Office (NRO)", "National Reconnaissance Office (NRO)"), - ("National Security Agency (NSA)", "National Security Agency (NSA)"), - ( - "National Security Agency-Central Security Service", - "National Security Agency-Central Security Service", - ), - ("Navy, Department of the", "Navy, Department of the"), - ("Office of Economic Adjustment", "Office of Economic Adjustment"), - ("Office of the Secretary of Defense", "Office of the Secretary of Defense"), - ("Pentagon Force Protection Agency", "Pentagon Force Protection Agency"), - ( - "Uniform Services University of the Health Sciences", - "Uniform Services University of the Health Sciences", - ), - ("US Cyber Command (USCYBERCOM)", "US Cyber Command (USCYBERCOM)"), - ( - "US Special Operations Command (USSOCOM)", - "US Special Operations Command (USSOCOM)", - ), - ("US Strategic Command (USSTRATCOM)", "US Strategic Command (USSTRATCOM)"), - ( - "US Transportation Command (USTRANSCOM)", - "US Transportation Command (USTRANSCOM)", - ), - ("Washington Headquarters Services", "Washington Headquarters Services"), -] - -APP_MIGRATION = [ - ("on_premise", translate("forms.task_order.app_migration.on_premise")), - ("cloud", translate("forms.task_order.app_migration.cloud")), - ("both", translate("forms.task_order.app_migration.both")), - ("none", translate("forms.task_order.app_migration.none")), - ("not_sure", translate("forms.task_order.app_migration.not_sure")), -] - -APPLICATION_COMPLEXITY = [ - ("storage", translate("forms.task_order.complexity.storage")), - ("data_analytics", translate("forms.task_order.complexity.data_analytics")), - ("conus", translate("forms.task_order.complexity.conus")), - ("oconus", translate("forms.task_order.complexity.oconus")), - ("tactical_edge", translate("forms.task_order.complexity.tactical_edge")), - ("not_sure", translate("forms.task_order.complexity.not_sure")), - ("other", translate("forms.task_order.complexity.other")), -] - -DEV_TEAM = [ - ("civilians", translate("forms.task_order.dev_team.civilians")), - ("military", translate("forms.task_order.dev_team.military")), - ("contractor", translate("forms.task_order.dev_team.contractor")), - ("other", translate("forms.task_order.dev_team.other")), -] - -TEAM_EXPERIENCE = [ - ("none", translate("forms.task_order.team_experience.none")), - ("planned", translate("forms.task_order.team_experience.planned")), - ("built_1", translate("forms.task_order.team_experience.built_1")), - ("built_3", translate("forms.task_order.team_experience.built_3")), - ("built_many", translate("forms.task_order.team_experience.built_many")), + ("navy", translate("forms.portfolio.defense_component.choices.navy")), + ("other", translate("forms.portfolio.defense_component.choices.other")), ] ENV_ROLE_NO_ACCESS = "No Access" diff --git a/atst/forms/portfolio.py b/atst/forms/portfolio.py index 307f463a..7f281787 100644 --- a/atst/forms/portfolio.py +++ b/atst/forms/portfolio.py @@ -1,33 +1,25 @@ from wtforms.fields import ( - RadioField, - SelectField, SelectMultipleField, StringField, TextAreaField, ) -from wtforms.validators import Length, Optional +from wtforms.validators import Length, InputRequired from wtforms.widgets import ListWidget, CheckboxInput -from .forms import BaseForm, remove_empty_string +from .forms import BaseForm from atst.utils.localization import translate -from .data import ( - APPLICATION_COMPLEXITY, - APP_MIGRATION, - DEV_TEAM, - SERVICE_BRANCHES, - TEAM_EXPERIENCE, -) +from .data import SERVICE_BRANCHES class PortfolioForm(BaseForm): name = StringField( - translate("forms.portfolio.name_label"), + translate("forms.portfolio.name.label"), validators=[ Length( min=4, max=100, - message=translate("forms.portfolio.name_length_validation_message"), + message=translate("forms.portfolio.name.length_validation_message"), ) ], ) @@ -35,78 +27,25 @@ class PortfolioForm(BaseForm): class PortfolioCreationForm(BaseForm): name = StringField( - translate("forms.portfolio.name_label"), + translate("forms.portfolio.name.label"), validators=[ Length( min=4, max=100, - message=translate("forms.portfolio.name_length_validation_message"), + message=translate("forms.portfolio.name.length_validation_message"), ) ], ) - - defense_component = SelectField( - translate("forms.task_order.defense_component_label"), + description = TextAreaField(translate("forms.portfolio.description.label"),) + defense_component = SelectMultipleField( choices=SERVICE_BRANCHES, - default="", - filters=[remove_empty_string], - ) - - description = TextAreaField( - translate("forms.task_order.scope_label"), - description=translate("forms.task_order.scope_description"), - ) - - app_migration = RadioField( - translate("forms.task_order.app_migration.label"), - description=translate("forms.task_order.app_migration.description"), - choices=APP_MIGRATION, - default="", - validators=[Optional()], - ) - - native_apps = RadioField( - translate("forms.task_order.native_apps.label"), - description=translate("forms.task_order.native_apps.description"), - choices=[("yes", "Yes"), ("no", "No"), ("not_sure", "Not Sure")], - default="", - validators=[Optional()], - ) - - complexity = SelectMultipleField( - translate("forms.task_order.complexity.label"), - description=translate("forms.task_order.complexity.description"), - choices=APPLICATION_COMPLEXITY, - default=None, widget=ListWidget(prefix_label=False), option_widget=CheckboxInput(), - ) - - complexity_other = StringField( - translate("forms.task_order.complexity_other_label"), - default=None, - filters=[remove_empty_string], - ) - - dev_team = SelectMultipleField( - translate("forms.task_order.dev_team.label"), - description=translate("forms.task_order.dev_team.description"), - choices=DEV_TEAM, - default=None, - widget=ListWidget(prefix_label=False), - option_widget=CheckboxInput(), - ) - - dev_team_other = StringField( - translate("forms.task_order.dev_team_other_label"), - default=None, - filters=[remove_empty_string], - ) - - team_experience = RadioField( - translate("forms.task_order.team_experience.label"), - description=translate("forms.task_order.team_experience.description"), - choices=TEAM_EXPERIENCE, - default="", - validators=[Optional()], + validators=[ + InputRequired( + message=translate( + "forms.portfolio.defense_component.validation_message" + ) + ) + ], ) diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 3ac5640b..a5e02e8b 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -7,13 +7,13 @@ from wtforms.fields import ( HiddenField, ) from wtforms.fields.html5 import DateField -from wtforms.validators import Required, Optional, Length, NumberRange, ValidationError +from wtforms.validators import Required, Length, NumberRange, ValidationError from flask_wtf import FlaskForm from numbers import Number from .data import JEDI_CLIN_TYPES from .fields import SelectField -from .forms import BaseForm +from .forms import BaseForm, remove_empty_string from atst.utils.localization import translate from flask import current_app as app @@ -61,9 +61,7 @@ class CLINForm(FlaskForm): coerce=coerce_enum, ) - number = StringField( - label=translate("task_orders.form.clin_number_label"), validators=[Optional()] - ) + number = StringField(label=translate("task_orders.form.clin_number_label")) start_date = DateField( translate("task_orders.form.pop_start"), description=translate("task_orders.form.pop_example"), @@ -136,7 +134,10 @@ class AttachmentForm(BaseForm): class TaskOrderForm(BaseForm): - number = StringField(label=translate("forms.task_order.number_description")) + number = StringField( + label=translate("forms.task_order.number_description"), + filters=[remove_empty_string], + ) pdf = FormField( AttachmentForm, label=translate("task_orders.form.supporting_docs_size_limit"), diff --git a/atst/models/application_invitation.py b/atst/models/application_invitation.py index d24cc54d..02be5e14 100644 --- a/atst/models/application_invitation.py +++ b/atst/models/application_invitation.py @@ -12,7 +12,10 @@ class ApplicationInvitation( __tablename__ = "application_invitations" application_role_id = Column( - UUID(as_uuid=True), ForeignKey("application_roles.id"), index=True + UUID(as_uuid=True), + ForeignKey("application_roles.id"), + index=True, + nullable=False, ) role = relationship( "ApplicationRole", diff --git a/atst/models/application_role.py b/atst/models/application_role.py index f8f7f201..d65ceac7 100644 --- a/atst/models/application_role.py +++ b/atst/models/application_role.py @@ -46,7 +46,9 @@ class ApplicationRole( UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=True ) - status = Column(SQLAEnum(Status, native_enum=False), default=Status.PENDING) + status = Column( + SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False + ) permission_sets = relationship( "PermissionSet", secondary=application_roles_permission_sets diff --git a/atst/models/clin.py b/atst/models/clin.py index 2802e292..2811bd6a 100644 --- a/atst/models/clin.py +++ b/atst/models/clin.py @@ -23,12 +23,12 @@ class CLIN(Base, mixins.TimestampsMixin): task_order_id = Column(ForeignKey("task_orders.id"), nullable=False) task_order = relationship("TaskOrder") - number = Column(String, nullable=True) - start_date = Column(Date, nullable=True) - end_date = Column(Date, nullable=True) - total_amount = Column(Numeric(scale=2), nullable=True) - obligated_amount = Column(Numeric(scale=2), nullable=True) - jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=True) + number = Column(String, nullable=False) + start_date = Column(Date, nullable=False) + end_date = Column(Date, nullable=False) + total_amount = Column(Numeric(scale=2), nullable=False) + obligated_amount = Column(Numeric(scale=2), nullable=False) + jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=False) # # NOTE: For now obligated CLINS are CLIN 1 + CLIN 3 @@ -65,4 +65,6 @@ class CLIN(Base, mixins.TimestampsMixin): @property def is_active(self): - return self.start_date <= date.today() <= self.end_date + return ( + self.start_date <= date.today() <= self.end_date + ) and self.task_order.signed_at diff --git a/atst/models/environment_role.py b/atst/models/environment_role.py index 541b6d40..5b3a2c27 100644 --- a/atst/models/environment_role.py +++ b/atst/models/environment_role.py @@ -43,7 +43,9 @@ class EnvironmentRole( COMPLETED = "completed" DISABLED = "disabled" - status = Column(SQLAEnum(Status, native_enum=False), default=Status.PENDING) + status = Column( + SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False + ) def __repr__(self): return "".format( diff --git a/atst/models/mixins/invites.py b/atst/models/mixins/invites.py index 69e016f8..18916dc4 100644 --- a/atst/models/mixins/invites.py +++ b/atst/models/mixins/invites.py @@ -31,23 +31,29 @@ class InvitesMixin(object): @declared_attr def inviter_id(cls): - return Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + return Column( + UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=False + ) @declared_attr def inviter(cls): return relationship("User", foreign_keys=[cls.inviter_id]) - status = Column(SQLAEnum(Status, native_enum=False, default=Status.PENDING)) + status = Column( + SQLAEnum(Status, native_enum=False, default=Status.PENDING, nullable=False) + ) - expiration_time = Column(TIMESTAMP(timezone=True)) + expiration_time = Column(TIMESTAMP(timezone=True), nullable=False) - token = Column(String, index=True, default=lambda: secrets.token_urlsafe()) + token = Column( + String, index=True, default=lambda: secrets.token_urlsafe(), nullable=False + ) email = Column(String, nullable=False) - dod_id = Column(String) - first_name = Column(String) - last_name = Column(String) + dod_id = Column(String, nullable=False) + first_name = Column(String, nullable=False) + last_name = Column(String, nullable=False) phone_number = Column(String) phone_ext = Column(String) diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index 08a65f1c..8cfc35d6 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -1,6 +1,5 @@ from sqlalchemy import Column, String from sqlalchemy.orm import relationship -from sqlalchemy.types import ARRAY from itertools import chain from atst.models.base import Base @@ -18,17 +17,11 @@ class Portfolio( __tablename__ = "portfolios" id = types.Id() - name = Column(String) - defense_component = Column(String) # Department of Defense Component - - app_migration = Column(String) # App Migration - complexity = Column(ARRAY(String)) # Application Complexity - complexity_other = Column(String) + name = Column(String, nullable=False) description = Column(String) - dev_team = Column(ARRAY(String)) # Development Team - dev_team_other = Column(String) - native_apps = Column(String) # Native Apps - team_experience = Column(String) # Team Experience + defense_component = Column( + String, nullable=False + ) # Department of Defense Component applications = relationship( "Application", diff --git a/atst/models/portfolio_invitation.py b/atst/models/portfolio_invitation.py index 55d895c6..4ab9088d 100644 --- a/atst/models/portfolio_invitation.py +++ b/atst/models/portfolio_invitation.py @@ -12,7 +12,7 @@ class PortfolioInvitation( __tablename__ = "portfolio_invitations" portfolio_role_id = Column( - UUID(as_uuid=True), ForeignKey("portfolio_roles.id"), index=True + UUID(as_uuid=True), ForeignKey("portfolio_roles.id"), index=True, nullable=False ) role = relationship( "PortfolioRole", diff --git a/atst/models/portfolio_role.py b/atst/models/portfolio_role.py index 500a0ddd..53204e82 100644 --- a/atst/models/portfolio_role.py +++ b/atst/models/portfolio_role.py @@ -52,7 +52,9 @@ class PortfolioRole( UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=True ) - status = Column(SQLAEnum(Status, native_enum=False), default=Status.PENDING) + status = Column( + SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False + ) permission_sets = relationship( "PermissionSet", secondary=portfolio_roles_permission_sets diff --git a/atst/models/task_order.py b/atst/models/task_order.py index c79d3b83..eba34147 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -1,5 +1,5 @@ -from datetime import timedelta from enum import Enum +from decimal import Decimal from sqlalchemy import Column, DateTime, ForeignKey, String from sqlalchemy.ext.hybrid import hybrid_property @@ -17,15 +17,16 @@ class Status(Enum): ACTIVE = "Active" UPCOMING = "Upcoming" EXPIRED = "Expired" - UNSIGNED = "Not signed" + UNSIGNED = "Unsigned" -SORT_ORDERING = { - status: order - for (order, status) in enumerate( - [Status.DRAFT, Status.ACTIVE, Status.UPCOMING, Status.EXPIRED, Status.UNSIGNED] - ) -} +SORT_ORDERING = [ + Status.ACTIVE, + Status.DRAFT, + Status.UPCOMING, + Status.EXPIRED, + Status.UNSIGNED, +] class TaskOrder(Base, mixins.TimestampsMixin): @@ -33,15 +34,12 @@ class TaskOrder(Base, mixins.TimestampsMixin): id = types.Id() - portfolio_id = Column(ForeignKey("portfolios.id")) + portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False) portfolio = relationship("Portfolio") - user_id = Column(ForeignKey("users.id")) - creator = relationship("User", foreign_keys="TaskOrder.user_id") - pdf_attachment_id = Column(ForeignKey("attachments.id")) _pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id]) - number = Column(String) # Task Order Number + number = Column(String, unique=True,) # Task Order Number signer_dod_id = Column(String) signed_at = Column(DateTime) @@ -134,12 +132,11 @@ class TaskOrder(Base, mixins.TimestampsMixin): @property def start_date(self): - return min((c.start_date for c in self.clins), default=self.time_created.date()) + return min((c.start_date for c in self.clins), default=None) @property def end_date(self): - default_end_date = self.start_date + timedelta(days=1) - return max((c.end_date for c in self.clins), default=default_end_date) + return max((c.end_date for c in self.clins), default=None) @property def days_to_expiration(self): @@ -173,6 +170,11 @@ class TaskOrder(Base, mixins.TimestampsMixin): # Faked for display purposes return 50 + @property + def invoiced_funds(self): + # TODO: implement this using reporting data from the CSP + return self.total_obligated_funds * Decimal(0.75) + @property def display_status(self): return self.status.value diff --git a/atst/models/user.py b/atst/models/user.py index 4ba23895..29b377d6 100644 --- a/atst/models/user.py +++ b/atst/models/user.py @@ -56,8 +56,8 @@ class User( email = Column(String) dod_id = Column(String, unique=True, nullable=False) - first_name = Column(String) - last_name = Column(String) + first_name = Column(String, nullable=False) + last_name = Column(String, nullable=False) phone_number = Column(String) phone_ext = Column(String) service_branch = Column(String) diff --git a/atst/routes/applications/index.py b/atst/routes/applications/index.py index dc9f4c9c..92b65398 100644 --- a/atst/routes/applications/index.py +++ b/atst/routes/applications/index.py @@ -1,7 +1,8 @@ -from flask import render_template +from flask import render_template, g from .blueprint import applications_bp from atst.domain.authz.decorator import user_can_access_decorator as user_can +from atst.domain.environment_roles import EnvironmentRoles from atst.models.permissions import Permissions @@ -23,4 +24,11 @@ def has_portfolio_applications(_user, portfolio=None, **_kwargs): message="view portfolio applications", ) def portfolio_applications(portfolio_id): - return render_template("applications/index.html") + user_env_roles = EnvironmentRoles.for_user(g.current_user.id, portfolio_id) + environment_access = { + env_role.environment_id: env_role.role for env_role in user_env_roles + } + + return render_template( + "applications/index.html", environment_access=environment_access + ) diff --git a/atst/routes/applications/settings.py b/atst/routes/applications/settings.py index a4aea550..f2d252a9 100644 --- a/atst/routes/applications/settings.py +++ b/atst/routes/applications/settings.py @@ -20,6 +20,7 @@ from atst.domain.permission_sets import PermissionSets from atst.utils.flash import formatted_flash as flash from atst.utils.localization import translate from atst.jobs import send_mail +from atst.routes.errors import log_error def get_environments_obj_for_app(application): @@ -234,7 +235,8 @@ def handle_update_member(application_id, application_role_id, form_data): flash("application_member_updated", user_name=app_role.user_name) - except GeneralCSPException: + except GeneralCSPException as exc: + log_error(exc) flash( "application_member_update_error", user_name=app_role.user_name, ) diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py index 06a4e783..0447be57 100644 --- a/atst/routes/portfolios/index.py +++ b/atst/routes/portfolios/index.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, timedelta +from datetime import datetime from flask import redirect, render_template, url_for, request as http_request, g @@ -11,21 +11,10 @@ from atst.domain.authz.decorator import user_can_access_decorator as user_can from atst.utils.flash import formatted_flash as flash -@portfolios_bp.route("/portfolios") -def portfolios(): - portfolios = Portfolios.for_user(g.current_user) - - if portfolios: - return render_template("portfolios/index.html", page=5, portfolios=portfolios) - else: - return render_template("portfolios/blank_slate.html") - - @portfolios_bp.route("/portfolios/new") -def new_portfolio(): +def new_portfolio_step_1(): form = PortfolioCreationForm() - - return render_template("portfolios/new.html", form=form) + return render_template("portfolios/new/step_1.html", form=form) @portfolios_bp.route("/portfolios", methods=["POST"]) @@ -38,16 +27,19 @@ def create_portfolio(): url_for("applications.portfolio_applications", portfolio_id=portfolio.id) ) else: - return render_template("portfolios/new.html", form=form), 400 + return render_template("portfolios/new/step_1.html", form=form), 400 @portfolios_bp.route("/portfolios//reports") @user_can(Permissions.VIEW_PORTFOLIO_REPORTS, message="view portfolio reports") def reports(portfolio_id): portfolio = Portfolios.get(g.current_user, portfolio_id) - today = date.today() - current_month = date(int(today.year), int(today.month), 15) - prev_month = current_month - timedelta(days=28) + + current_obligated_funds = Reports.obligated_funds_by_JEDI_clin(portfolio) + + if any(map(lambda clin: clin["remaining"] < 0, current_obligated_funds)): + flash("insufficient_funds") + # wrapped in str() because the sum of obligated funds returns a Decimal object total_portfolio_value = str( sum( @@ -59,12 +51,10 @@ def reports(portfolio_id): "portfolios/reports/index.html", portfolio=portfolio, total_portfolio_value=total_portfolio_value, - current_obligated_funds=Reports.obligated_funds_by_JEDI_clin(portfolio), + current_obligated_funds=current_obligated_funds, expired_task_orders=Reports.expired_task_orders(portfolio), - monthly_totals=Reports.monthly_totals(portfolio), - current_month=current_month, - prev_month=prev_month, - now=datetime.now(), # mocked datetime of reporting data retrival + monthly_spending=Reports.monthly_spending(portfolio), + retrieved=datetime.now(), # mocked datetime of reporting data retrival ) diff --git a/atst/routes/task_orders/index.py b/atst/routes/task_orders/index.py index a58ea8da..e02780ca 100644 --- a/atst/routes/task_orders/index.py +++ b/atst/routes/task_orders/index.py @@ -6,7 +6,6 @@ from atst.domain.portfolios import Portfolios from atst.domain.task_orders import TaskOrders from atst.forms.task_order import SignatureForm from atst.models import Permissions -from atst.models.task_order import Status as TaskOrderStatus @task_orders_bp.route("/task_orders//review") @@ -28,14 +27,9 @@ def review_task_order(task_order_id): @user_can(Permissions.VIEW_PORTFOLIO_FUNDING, message="view portfolio funding") def portfolio_funding(portfolio_id): portfolio = Portfolios.get(g.current_user, portfolio_id) - task_orders = TaskOrders.sort(portfolio.task_orders) - label_colors = { - TaskOrderStatus.DRAFT: "warning", - TaskOrderStatus.ACTIVE: "success", - TaskOrderStatus.UPCOMING: "info", - TaskOrderStatus.EXPIRED: "error", - TaskOrderStatus.UNSIGNED: "purple", - } + task_orders = TaskOrders.sort_by_status(portfolio.task_orders) + to_count = len(portfolio.task_orders) + # TODO: Get expended amount from the CSP return render_template( - "task_orders/index.html", task_orders=task_orders, label_colors=label_colors + "task_orders/index.html", task_orders=task_orders, to_count=to_count ) diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index 24460204..f6e53b75 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -10,7 +10,7 @@ from flask import ( from .blueprint import task_orders_bp from atst.domain.authz.decorator import user_can_access_decorator as user_can -from atst.domain.exceptions import NoAccessError +from atst.domain.exceptions import NoAccessError, AlreadyExistsError from atst.domain.task_orders import TaskOrders from atst.forms.task_order import TaskOrderForm, SignatureForm from atst.models.permissions import Permissions @@ -50,7 +50,26 @@ def render_task_orders_edit( return render_template(template, **render_args) -def update_task_order( +def update_task_order(form, portfolio_id=None, task_order_id=None, flash_invalid=True): + if form.validate(flash_invalid=flash_invalid): + task_order = None + try: + if task_order_id: + task_order = TaskOrders.update(task_order_id, **form.data) + portfolio_id = task_order.portfolio_id + else: + task_order = TaskOrders.create(portfolio_id, **form.data) + + return task_order + + except AlreadyExistsError: + flash("task_order_number_error", to_number=form.data["number"]) + return False + else: + return False + + +def update_and_render_next( form_data, next_page, current_template, portfolio_id=None, task_order_id=None ): form = None @@ -60,14 +79,8 @@ def update_task_order( else: form = TaskOrderForm(form_data) - if form.validate(): - task_order = None - if task_order_id: - task_order = TaskOrders.update(task_order_id, **form.data) - portfolio_id = task_order.portfolio_id - else: - task_order = TaskOrders.create(g.current_user, portfolio_id, **form.data) - + task_order = update_task_order(form, portfolio_id, task_order_id) + if task_order: return redirect(url_for(next_page, task_order_id=task_order.id)) else: return ( @@ -149,7 +162,7 @@ def submit_form_step_one_add_pdf(portfolio_id=None, task_order_id=None): next_page = "task_orders.form_step_two_add_number" current_template = "task_orders/step_1.html" - return update_task_order( + return update_and_render_next( form_data, next_page, current_template, @@ -176,14 +189,8 @@ def cancel_edit(task_order_id=None, portfolio_id=None): else: form = TaskOrderForm(form_data) - if form.validate(flash_invalid=False): - task_order = None - if task_order_id: - task_order = TaskOrders.update(task_order_id, **form.data) - else: - task_order = TaskOrders.create( - g.current_user, portfolio_id, **form.data - ) + update_task_order(form, portfolio_id, task_order_id, flash_invalid=False) + elif not save and task_order_id: TaskOrders.delete(task_order_id) @@ -207,7 +214,7 @@ def submit_form_step_two_add_number(task_order_id): next_page = "task_orders.form_step_three_add_clins" current_template = "task_orders/step_2.html" - return update_task_order( + return update_and_render_next( form_data, next_page, current_template, task_order_id=task_order_id ) @@ -227,7 +234,7 @@ def submit_form_step_three_add_clins(task_order_id): next_page = "task_orders.form_step_four_review" current_template = "task_orders/step_3.html" - return update_task_order( + return update_and_render_next( form_data, next_page, current_template, task_order_id=task_order_id ) diff --git a/atst/utils/flash.py b/atst/utils/flash.py index 73bdbc4c..fabba4d2 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -96,6 +96,11 @@ MESSAGES = { "message_template": "

Please see below.

", "category": "error", }, + "insufficient_funds": { + "title_template": "Insufficient Funds", + "message_template": "", + "category": "warning", + }, "logged_out": { "title_template": translate("flash.logged_out"), "message_template": """ @@ -160,6 +165,11 @@ MESSAGES = { "message_template": translate("task_orders.form.draft_alert_message"), "category": "warning", }, + "task_order_number_error": { + "title_template": "", + "message_template": """{{ 'flash.task_order_number_error.message' | translate({ 'to_number': to_number }) }}""", + "category": "error", + }, "task_order_submitted": { "title_template": "Your Task Order has been uploaded successfully.", "message_template": """ diff --git a/config/base.ini b/config/base.ini index 257059e4..2cc8fd93 100644 --- a/config/base.ini +++ b/config/base.ini @@ -1,5 +1,8 @@ [default] ASSETS_URL +AZURE_ACCOUNT_NAME +AZURE_STORAGE_KEY +AZURE_TO_BUCKET_NAME BLOB_STORAGE_URL=http://localhost:8000/ CAC_URL = http://localhost:8000/login-redirect CA_CHAIN = ssl/server-certs/ca-chain.pem @@ -15,6 +18,11 @@ DISABLE_CRL_CHECK = false ENVIRONMENT = dev LIMIT_CONCURRENT_SESSIONS = false LOG_JSON = false +MAIL_PASSWORD +MAIL_PORT +MAIL_SENDER +MAIL_SERVER +MAIL_TLS PERMANENT_SESSION_LIFETIME = 1800 PGDATABASE = atat PGHOST = localhost @@ -24,7 +32,10 @@ PGSSLMODE = prefer PGSSLROOTCERT PGUSER = postgres PORT=8000 -REDIS_URI = redis://localhost:6379 +REDIS_HOST=localhost:6379 +REDIS_PASSWORD +REDIS_TLS=False +REDIS_USER SECRET_KEY = change_me_into_something_secret SERVER_NAME SESSION_COOKIE_NAME=atat diff --git a/config/ci.ini b/config/ci.ini index 34682fcc..0a6af8c2 100644 --- a/config/ci.ini +++ b/config/ci.ini @@ -1,8 +1,6 @@ [default] -DEBUG = true -PGHOST = postgreshost -PGDATABASE = atat_test -REDIS_URI = redis://redishost:6379 CRL_STORAGE_CONTAINER = tests/fixtures/crl -WTF_CSRF_ENABLED = false CSP=mock-test +DEBUG = true +PGDATABASE = atat_test +WTF_CSRF_ENABLED = false diff --git a/deploy/README.md b/deploy/README.md index be66290d..c0683ae0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -14,6 +14,7 @@ The production configuration (azure.atat.code.mil, currently) is reflected in th - AUTH_DOMAIN: The host domain for the authentication endpoint for the environment. - KV_MI_ID: the fully qualified id (path) of the managed identity for the key vault (instructions on retrieving this are down in section on [Setting up FlexVol](#configuring-the-identity)). Example: /subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/RESOURCE_GROUP_NAME/providers/Microsoft.ManagedIdentity/userAssignedIdentities/MANAGED_IDENTITY_NAME - KV_MI_CLIENT_ID: The client id of the managed identity for the key vault. This is a GUID. +- TENANT_ID: The id of the active directory tenant in which the cluster and it's associated users exist. This is a GUID. We use envsubst to substitute values for these variables. There is a wrapper script (script/k8s_config) that will output the compiled configuration, using a combination of kustomize and envsubst. @@ -36,35 +37,6 @@ If you are satisfied with the output from the diff, you can apply the new config ## Secrets and Configuration -### atst-overrides.ini - -Production configuration values are provided to the ATAT Flask app by writing an `atst-overrides.ini` file to the running Docker container. This file is stored as a Kubernetes secret. It contains configuration information for the database connection, mailer, etc. - -To update the configuration, you can do the following: - -``` -kubectl -n atat get secret atst-config-ini -o=jsonpath='{.data.override\.ini}' | base64 --decode > override.ini -``` - -This base64 decodes the secret and writes it to a local file called `override.ini`. Make any necessary config changes to that file. - -To apply the new config, first delete the existing copy of the secret: - -``` -kubectl -n atat delete secret atst-config-ini -``` - -Then create a new copy of the secret from your updated copy: - -``` -kubectl -n atat create secret generic atst-config-ini --from-file=./override.ini -``` - -Notes: - -- Be careful not to check the override.ini file into source control. -- Be careful not to overwrite one CSP cluster's config with the other's. This will break everything. - ### nginx-htpasswd If the site is running in dev mode, the `/login-dev` endpoint is available. This endpoint is protected by basic HTTP auth. To create a new password file, run: @@ -169,13 +141,40 @@ Then: kubectl -n atat create secret tls azure-atat-code-mil-tls --key="[path to the private key]" --cert="[path to the full chain]" ``` +### Create the Diffie-Hellman parameters + +Diffie-Hellman parameters allow per-session encryption of SSL traffic to help improve security. We currently store our parameters in KeyVault, the value can be updated using the following command. Note: Generating the new paramter can take over 10 minutes and there won't be any output while it's running. +``` +az keyvault secret set --vault-name --name --value "$(openssl genpkey -genparam -algorithm DH -outform pem -pkeyopt dh_paramgen_prime_len:4096 2> /dev/null)" +``` --- +# Secrets Management + +Secrets, keys, and certificates are managed from Azure Key Vault. These items are mounted into the containers at runtime using the FlexVol implementation described below. + +The following are mounted into the NGINX container in the atst pod: + +- The TLS certs for the site +- The DH parameter for TLS connections + +These are mounted into every instance of the Flask application container (the atst container, the celery worker, etc.): + +- The Azure storage key used to access blob storage (AZURE_STORAGE_KEY) +- The password for the SMTP server used to send mail (MAIL_PASSWORD) +- The Postgres database user password (PGPASSWORD) +- The Redis user password (REDIS_PASSWORD) +- The Flask secret key used for session signing and generating CSRF tokens (SECRET_KEY) + +Secrets should be added to Key Vault with the following naming pattern: [branch/environment]-[all-caps config setting name]. Note that Key Vault does not support underscores. Substitute hyphens. For example, the config setting for the SMTP server password is MAIL_SERVER. The corresponding secret name in Key Vault is "master-MAIL-SERVER" for the credential used in the primary environment.These secrets are mounted into the containers via FlexVol. + +To add or manage secrets, keys, and certificates in Key Vault, see the [documentation](https://docs.microsoft.com/en-us/azure/key-vault/quick-create-cli). + # Setting Up FlexVol for Secrets ## Preparing Azure Environment -A Key Vault will need to be created. Save it's full id (the full path) for use later. +A Key Vault will need to be created. Save its full id (the full path) for use later. ## Preparing Cluster @@ -217,3 +216,45 @@ Example values: 5. The file `deploy/azure/aadpodidentity.yml` is templated via Kustomize, so you'll need to include clientId (as `KV_MI_CLIENT_ID`) and id (as `KV_MI_ID`) of the managed identity as part of the call to Kustomize. +## Using the FlexVol + +There are 3 steps to using the FlexVol to access secrets from KeyVault + +1. For the resource in which you would like to mount a FlexVol, add a metadata label with the selector from `aadpodidentity.yml` + ``` + metadata: + labels: + app: atst + role: web + aadpodidbinding: atat-kv-id-binding + ``` + +2. Register the FlexVol as a mount and specifiy which secrets you want to mount, along with the file name they should have. The `keyvaultobjectnames`, `keyvaultobjectaliases`, and `keyvaultobjecttypes` correspond to one another, positionally. They are passed as semicolon delimited strings, examples below. + + ``` + - name: volume-of-secrets + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "" + keyvaultobjectnames: "mysecret;mykey;mycert" + keyvaultobjectaliases: "mysecret.pem;mykey.txt;mycert.crt" + keyvaultobjecttypes: "secret;key;cert" + tenantid: $TENANT_ID + ``` + +3. Tell the resource where to mount your new volume, using the same name that you specified for the volume above. + ``` + - name: nginx-secret + mountPath: "/usr/secrets/" + readOnly: true + ``` + +4. Once applied, the directory specified in the `mountPath` argument will contain the files you specified in the flexVolume. In our case, you would be able to do this: + ``` + $ kubectl exec -it CONTAINER_NAME -c atst ls /usr/secrets + mycert.crt + mykey.txt + mysecret.pem + ``` diff --git a/deploy/azure/atst-envvars-configmap.yml b/deploy/azure/atst-envvars-configmap.yml index 8dd80237..d2b0ba45 100644 --- a/deploy/azure/atst-envvars-configmap.yml +++ b/deploy/azure/atst-envvars-configmap.yml @@ -6,15 +6,30 @@ metadata: namespace: atat data: ASSETS_URL: https://atat-cdn.azureedge.net/ + AZURE_ACCOUNT_NAME: atat + AZURE_TO_BUCKET_NAME: task-order-pdfs BLOB_STORAGE_URL: https://atat.blob.core.windows.net/ - CELERY_DEFAULT_QUEUE: celery-master + CAC_URL: https://auth-staging.atat.code.mil/login-redirect CDN_ORIGIN: https://azure.atat.code.mil + CELERY_DEFAULT_QUEUE: celery-master CSP: azure + DEBUG: "0" FLASK_ENV: master LOG_JSON: "true" - OVERRIDE_CONFIG_FULLPATH: /opt/atat/atst/atst-overrides.ini + MAIL_PORT: "587" + MAIL_SENDER: postmaster@atat.code.mil + MAIL_SERVER: smtp.mailgun.org + MAIL_TLS: "true" + OVERRIDE_CONFIG_DIRECTORY: /config + PGAPPNAME: atst + PGDATABASE: staging + PGHOST: atat-db.postgres.database.azure.com + PGPORT: "5432" PGSSLMODE: verify-full PGSSLROOTCERT: /opt/atat/atst/ssl/pgsslrootcert.crt + PGUSER: atat_master@atat-db + REDIS_HOST: atat.redis.cache.windows.net:6380 + REDIS_TLS: "true" STATIC_URL: https://atat-cdn.azureedge.net/static/ TZ: UTC UWSGI_CONFIG_FULLPATH: /opt/atat/atst/uwsgi.ini diff --git a/deploy/azure/atst-nginx-configmap.yml b/deploy/azure/atst-nginx-configmap.yml index e19c6d54..5f51c7d6 100644 --- a/deploy/azure/atst-nginx-configmap.yml +++ b/deploy/azure/atst-nginx-configmap.yml @@ -5,8 +5,10 @@ metadata: name: atst-nginx namespace: atat data: - nginx-config: |- + atst.conf: |- server { + access_log /var/log/nginx/access.log json; + listen ${PORT_PREFIX}342; server_name ${MAIN_DOMAIN}; root /usr/share/nginx/html; @@ -18,6 +20,8 @@ data: } } server { + access_log /var/log/nginx/access.log json; + listen ${PORT_PREFIX}343; server_name ${AUTH_DOMAIN}; root /usr/share/nginx/html; @@ -29,12 +33,17 @@ data: } } server { + access_log /var/log/nginx/access.log json; + server_name ${MAIN_DOMAIN}; # access_log /var/log/nginx/access.log json; listen ${PORT_PREFIX}442 ssl; listen [::]:${PORT_PREFIX}442 ssl ipv6only=on; - ssl_certificate /etc/ssl/private/atat.crt; - ssl_certificate_key /etc/ssl/private/atat.key; + ssl_certificate /etc/ssl/atat.crt; + ssl_certificate_key /etc/ssl/atat.key; + # additional SSL/TLS settings + include /etc/nginx/snippets/ssl.conf; + location /login-redirect { return 301 https://auth-azure.atat.code.mil$request_uri; } @@ -58,18 +67,20 @@ data: } } server { - # access_log /var/log/nginx/access.log json; + access_log /var/log/nginx/access.log json; + server_name ${AUTH_DOMAIN}; listen ${PORT_PREFIX}443 ssl; listen [::]:${PORT_PREFIX}443 ssl ipv6only=on; - ssl_certificate /etc/ssl/private/atat.crt; - ssl_certificate_key /etc/ssl/private/atat.key; + ssl_certificate /etc/ssl/atat.crt; + ssl_certificate_key /etc/ssl/atat.key; # Request and validate client certificate ssl_verify_client on; ssl_verify_depth 10; ssl_client_certificate /etc/ssl/client-ca-bundle.pem; - # Guard against HTTPS -> HTTP downgrade - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; always"; + # additional SSL/TLS settings + include /etc/nginx/snippets/ssl.conf; + location / { return 301 https://azure.atat.code.mil$request_uri; } @@ -88,3 +99,18 @@ data: uwsgi_param HTTP_X_REQUEST_ID $request_id; } } + 00json_log.conf: |- + log_format json escape=json + '{' + '"timestamp":"$time_iso8601",' + '"msec":"$msec",' + '"request_id":"$request_id",' + '"remote_addr":"$remote_addr",' + '"remote_user":"$remote_user",' + '"request":"$request",' + '"status":$status,' + '"body_bytes_sent":$body_bytes_sent,' + '"referer":"$http_referer",' + '"user_agent":"$http_user_agent",' + '"http_x_forwarded_for":"$http_x_forwarded_for"' + '}'; diff --git a/deploy/azure/atst-worker-envvars-configmap.yml b/deploy/azure/atst-worker-envvars-configmap.yml index c3522f70..4323952c 100644 --- a/deploy/azure/atst-worker-envvars-configmap.yml +++ b/deploy/azure/atst-worker-envvars-configmap.yml @@ -5,9 +5,25 @@ metadata: name: atst-worker-envvars namespace: atat data: + AZURE_ACCOUNT_NAME: atat + AZURE_TO_BUCKET_NAME: task-order-pdfs + CAC_URL: https://auth-staging.atat.code.mil/login-redirect CELERY_DEFAULT_QUEUE: celery-master - DISABLE_CRL_CHECK: "True" + DEBUG: "0" + DISABLE_CRL_CHECK: "true" + MAIL_PORT: "587" + MAIL_SENDER: postmaster@atat.code.mil + MAIL_SERVER: smtp.mailgun.org + MAIL_TLS: "true" + OVERRIDE_CONFIG_DIRECTORY: /config + PGAPPNAME: atst + PGDATABASE: staging + PGHOST: atat-db.postgres.database.azure.com + PGPORT: "5432" PGSSLMODE: verify-full PGSSLROOTCERT: /opt/atat/atst/ssl/pgsslrootcert.crt + PGUSER: atat_master@atat-db + REDIS_HOST: atat.redis.cache.windows.net:6380 + REDIS_TLS: "true" SERVER_NAME: azure.atat.code.mil TZ: UTC diff --git a/deploy/azure/azure.yml b/deploy/azure/azure.yml index 8d46fa4b..8fe7fd87 100644 --- a/deploy/azure/azure.yml +++ b/deploy/azure/azure.yml @@ -23,6 +23,7 @@ spec: labels: app: atst role: web + aadpodidbinding: atat-kv-id-binding spec: securityContext: fsGroup: 101 @@ -30,12 +31,9 @@ spec: - name: atst image: $CONTAINER_IMAGE envFrom: - - configMapRef: - name: atst-envvars + - configMapRef: + name: atst-envvars volumeMounts: - - name: atst-config - mountPath: "/opt/atat/atst/atst-overrides.ini" - subPath: atst-overrides.ini - name: nginx-client-ca-bundle mountPath: "/opt/atat/atst/ssl/server-certs/ca-chain.pem" subPath: client-ca-bundle.pem @@ -49,6 +47,8 @@ spec: - name: uwsgi-config mountPath: "/opt/atat/atst/uwsgi.ini" subPath: uwsgi.ini + - name: flask-secret + mountPath: "/config" - name: nginx image: nginx:alpine ports: @@ -62,37 +62,32 @@ spec: name: auth volumeMounts: - name: nginx-config - mountPath: "/etc/nginx/conf.d/atst.conf" - subPath: atst.conf + mountPath: "/etc/nginx/conf.d/" - name: uwsgi-socket-dir mountPath: "/var/run/uwsgi" - name: nginx-htpasswd mountPath: "/etc/nginx/.htpasswd" subPath: .htpasswd - - name: tls - mountPath: "/etc/ssl/private" - name: nginx-client-ca-bundle - mountPath: "/etc/ssl/" + mountPath: "/etc/ssl/client-ca-bundle.pem" + subPath: "client-ca-bundle.pem" - name: acme mountPath: "/usr/share/nginx/html/.well-known/acme-challenge/" + - name: snippets + mountPath: "/etc/nginx/snippets/" + - name: nginx-secret + mountPath: "/etc/ssl/" volumes: - - name: atst-config - secret: - secretName: atst-config-ini - items: - - key: override.ini - path: atst-overrides.ini - mode: 0644 - name: nginx-client-ca-bundle configMap: name: nginx-client-ca-bundle - defaultMode: 0666 + defaultMode: 0444 + items: + - key: "client-ca-bundle.pem" + path: "client-ca-bundle.pem" - name: nginx-config configMap: name: atst-nginx - items: - - key: nginx-config - path: atst.conf - name: uwsgi-socket-dir emptyDir: medium: Memory @@ -100,19 +95,9 @@ spec: secret: secretName: atst-nginx-htpasswd items: - - key: htpasswd - path: .htpasswd - mode: 0640 - - name: tls - secret: - secretName: azure-atat-code-mil-tls - items: - - key: tls.crt - path: atat.crt - mode: 0644 - - key: tls.key - path: atat.key - mode: 0640 + - key: htpasswd + path: .htpasswd + mode: 0640 - name: crls-vol persistentVolumeClaim: claimName: crls-vol-claim @@ -120,9 +105,9 @@ spec: configMap: name: pgsslrootcert items: - - key: cert - path: pgsslrootcert.crt - mode: 0666 + - key: cert + path: pgsslrootcert.crt + mode: 0666 - name: acme configMap: name: acme-challenges @@ -132,9 +117,32 @@ spec: name: uwsgi-config defaultMode: 0666 items: - - key: uwsgi.ini - path: uwsgi.ini - mode: 0644 + - key: uwsgi.ini + path: uwsgi.ini + mode: 0644 + - name: snippets + configMap: + name: nginx-snippets + - name: nginx-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "dhparam4096;master-cert;master-cert" + keyvaultobjectaliases: "dhparam.pem;atat.key;atat.crt" + keyvaultobjecttypes: "secret;secret;secret" + tenantid: $TENANT_ID + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID --- apiVersion: extensions/v1beta1 kind: Deployment @@ -155,47 +163,51 @@ spec: labels: app: atst role: worker + aadpodidbinding: atat-kv-id-binding spec: securityContext: fsGroup: 101 containers: - name: atst-worker image: $CONTAINER_IMAGE - args: [ - "/opt/atat/atst/.venv/bin/python", - "/opt/atat/atst/.venv/bin/celery", - "-A", - "celery_worker.celery", - "worker", - "--loglevel=info" - ] + args: + [ + "/opt/atat/atst/.venv/bin/python", + "/opt/atat/atst/.venv/bin/celery", + "-A", + "celery_worker.celery", + "worker", + "--loglevel=info", + ] envFrom: - - configMapRef: - name: atst-envvars - - configMapRef: - name: atst-worker-envvars + - configMapRef: + name: atst-envvars + - configMapRef: + name: atst-worker-envvars volumeMounts: - - name: atst-config - mountPath: "/opt/atat/atst/atst-overrides.ini" - subPath: atst-overrides.ini - name: pgsslrootcert mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt" subPath: pgsslrootcert.crt + - name: flask-secret + mountPath: "/config" volumes: - - name: atst-config - secret: - secretName: atst-config-ini - items: - - key: override.ini - path: atst-overrides.ini - mode: 0644 - name: pgsslrootcert configMap: name: pgsslrootcert items: - - key: cert - path: pgsslrootcert.crt - mode: 0666 + - key: cert + path: pgsslrootcert.crt + mode: 0666 + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID --- apiVersion: extensions/v1beta1 kind: Deployment @@ -216,47 +228,51 @@ spec: labels: app: atst role: beat + aadpodidbinding: atat-kv-id-binding spec: securityContext: fsGroup: 101 containers: - name: atst-beat image: $CONTAINER_IMAGE - args: [ - "/opt/atat/atst/.venv/bin/python", - "/opt/atat/atst/.venv/bin/celery", - "-A", - "celery_worker.celery", - "beat", - "--loglevel=info" - ] + args: + [ + "/opt/atat/atst/.venv/bin/python", + "/opt/atat/atst/.venv/bin/celery", + "-A", + "celery_worker.celery", + "beat", + "--loglevel=info", + ] envFrom: - - configMapRef: - name: atst-envvars - - configMapRef: - name: atst-worker-envvars + - configMapRef: + name: atst-envvars + - configMapRef: + name: atst-worker-envvars volumeMounts: - - name: atst-config - mountPath: "/opt/atat/atst/atst-overrides.ini" - subPath: atst-overrides.ini - name: pgsslrootcert mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt" subPath: pgsslrootcert.crt + - name: flask-secret + mountPath: "/config" volumes: - - name: atst-config - secret: - secretName: atst-config-ini - items: - - key: override.ini - path: atst-overrides.ini - mode: 0644 - name: pgsslrootcert configMap: name: pgsslrootcert items: - - key: cert - path: pgsslrootcert.crt - mode: 0666 + - key: cert + path: pgsslrootcert.crt + mode: 0666 + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID --- apiVersion: v1 kind: Service @@ -268,12 +284,12 @@ metadata: spec: loadBalancerIP: 13.92.235.6 ports: - - port: 80 - targetPort: 8342 - name: http - - port: 443 - targetPort: 8442 - name: https + - port: 80 + targetPort: 8342 + name: http + - port: 443 + targetPort: 8442 + name: https selector: role: web type: LoadBalancer @@ -288,12 +304,12 @@ metadata: spec: loadBalancerIP: 23.100.24.41 ports: - - port: 80 - targetPort: 8343 - name: http - - port: 443 - targetPort: 8443 - name: https + - port: 80 + targetPort: 8343 + name: http + - port: 443 + targetPort: 8443 + name: https selector: role: web type: LoadBalancer diff --git a/deploy/azure/crls-sync.yaml b/deploy/azure/crls-sync.yaml index f5bcdcf8..5fdcd7b8 100644 --- a/deploy/azure/crls-sync.yaml +++ b/deploy/azure/crls-sync.yaml @@ -5,9 +5,16 @@ metadata: namespace: atat spec: schedule: "0 * * * *" + concurrencyPolicy: Replace + successfulJobsHistoryLimit: 1 jobTemplate: spec: template: + metadata: + labels: + app: atst + role: crl-sync + aadpodidbinding: atat-kv-id-binding spec: restartPolicy: OnFailure containers: @@ -25,19 +32,21 @@ spec: - configMapRef: name: atst-worker-envvars volumeMounts: - - name: atst-config - mountPath: "/opt/atat/atst/atst-overrides.ini" - subPath: atst-overrides.ini - name: crls-vol mountPath: "/opt/atat/atst/crls" + - name: flask-secret + mountPath: "/config" volumes: - - name: atst-config - secret: - secretName: atst-config-ini - items: - - key: override.ini - path: atst-overrides.ini - mode: 0644 - name: crls-vol persistentVolumeClaim: claimName: crls-vol-claim + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID diff --git a/deploy/azure/kustomization.yaml b/deploy/azure/kustomization.yaml index 43e6f813..9dee809c 100644 --- a/deploy/azure/kustomization.yaml +++ b/deploy/azure/kustomization.yaml @@ -11,3 +11,4 @@ resources: - nginx-client-ca-bundle.yml - acme-challenges.yml - aadpodidentity.yml + - nginx-snippets.yml diff --git a/deploy/azure/nginx-snippets.yml b/deploy/azure/nginx-snippets.yml new file mode 100644 index 00000000..916d9524 --- /dev/null +++ b/deploy/azure/nginx-snippets.yml @@ -0,0 +1,24 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-snippets + namespace: atat +data: + ssl.conf: |- + # Guard against HTTPS -> HTTP downgrade + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; always"; + # Set SSL protocols, ciphers, and related options + ssl_protocols TLSv1.3 TLSv1.2; + ssl_ciphers TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers on; + ssl_ecdh_curve X25519:prime256v1:secp384r1; + ssl_dhparam /etc/ssl/dhparam.pem; + # SSL session options + ssl_session_timeout 4h; + ssl_session_cache shared:SSL:10m; # 1mb = ~4000 sessions + ssl_session_tickets off; + # OCSP Stapling + ssl_stapling on; + ssl_stapling_verify on; + resolver 8.8.8.8 8.8.4.4; diff --git a/deploy/overlays/staging/flex_vol.yml b/deploy/overlays/staging/flex_vol.yml new file mode 100644 index 00000000..0efa4044 --- /dev/null +++ b/deploy/overlays/staging/flex_vol.yml @@ -0,0 +1,62 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: atst +spec: + template: + spec: + volumes: + - name: nginx-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "dhparam4096;staging-cert;staging-cert" + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "staging-AZURE-STORAGE-KEY;staging-MAIL-PASSWORD;staging-PGPASSWORD;staging-REDIS-PASSWORD;staging-SECRET-KEY" +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: atst-worker +spec: + template: + spec: + volumes: + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "staging-AZURE-STORAGE-KEY;staging-MAIL-PASSWORD;staging-PGPASSWORD;staging-REDIS-PASSWORD;staging-SECRET-KEY" +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: atst-beat +spec: + template: + spec: + volumes: + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "staging-AZURE-STORAGE-KEY;staging-MAIL-PASSWORD;staging-PGPASSWORD;staging-REDIS-PASSWORD;staging-SECRET-KEY" +--- +apiVersion: batch/v1beta1 +kind: CronJob +metadata: + name: crls +spec: + jobTemplate: + spec: + template: + spec: + volumes: + - name: flask-secret + flexVolume: + options: + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "staging-AZURE-STORAGE-KEY;staging-MAIL-PASSWORD;staging-PGPASSWORD;staging-REDIS-PASSWORD;staging-SECRET-KEY" diff --git a/deploy/overlays/staging/kustomization.yaml b/deploy/overlays/staging/kustomization.yaml index 83450cf5..38251002 100644 --- a/deploy/overlays/staging/kustomization.yaml +++ b/deploy/overlays/staging/kustomization.yaml @@ -7,6 +7,7 @@ patchesStrategicMerge: - replica_count.yml - ports.yml - envvars.yml + - flex_vol.yml patchesJson6902: - target: group: extensions diff --git a/deploy/shared/migration.yaml b/deploy/shared/migration.yaml index d571c84d..b5161114 100644 --- a/deploy/shared/migration.yaml +++ b/deploy/shared/migration.yaml @@ -7,6 +7,11 @@ spec: ttlSecondsAfterFinished: 100 backoffLimit: 2 template: + metadata: + labels: + app: atst + role: migration + aadpodidbinding: atat-kv-id-binding spec: containers: - name: migration @@ -28,20 +33,12 @@ spec: - configMapRef: name: atst-worker-envvars volumeMounts: - - name: atst-config - mountPath: "/opt/atat/atst/atst-overrides.ini" - subPath: atst-overrides.ini - name: pgsslrootcert mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt" subPath: pgsslrootcert.crt + - name: flask-secret + mountPath: "/config" volumes: - - name: atst-config - secret: - secretName: atst-config-ini - items: - - key: override.ini - path: atst-overrides.ini - mode: 0644 - name: pgsslrootcert configMap: name: pgsslrootcert @@ -49,4 +46,14 @@ spec: - key: cert path: pgsslrootcert.crt mode: 0666 + - name: flask-secret + flexVolume: + driver: "azure/kv" + options: + usepodidentity: "true" + keyvaultname: "atat-vault-test" + keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY" + keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY" + keyvaultobjecttypes: "secret;secret;secret;secret;key" + tenantid: $TENANT_ID restartPolicy: Never diff --git a/fixtures/fixture_spend_data.json b/fixtures/fixture_spend_data.json new file mode 100644 index 00000000..2a59eadf --- /dev/null +++ b/fixtures/fixture_spend_data.json @@ -0,0 +1,390 @@ +{ + "A-Wing": { + "applications": [ + { + "name": "LC04", + "environments": [ + { + "name": "Integ", + "spending": { + "this_month": { + "JEDI_CLIN_1": 663, + "JEDI_CLIN_2": 397 + }, + "last_month": { + "JEDI_CLIN_1": 590, + "JEDI_CLIN_2": 829 + }, + "total": { + "JEDI_CLIN_1": 42467, + "JEDI_CLIN_2": 33873 + } + } + }, + { + "name": "PreProd", + "spending": { + "this_month": { + "JEDI_CLIN_1": 1000, + "JEDI_CLIN_2": 626 + }, + "last_month": { + "JEDI_CLIN_1": 685, + "JEDI_CLIN_2": 331 + }, + "total": { + "JEDI_CLIN_1": 21874, + "JEDI_CLIN_2": 25506 + } + } + }, + { + "name": "Prod", + "spending": { + "this_month": { + "JEDI_CLIN_1": 856, + "JEDI_CLIN_2": 627 + }, + "last_month": { + "JEDI_CLIN_1": 921, + "JEDI_CLIN_2": 473 + }, + "total": { + "JEDI_CLIN_1": 35566, + "JEDI_CLIN_2": 42514 + } + } + } + ] + }, + { + "name": "SF18", + "environments": [ + { + "name": "Integ", + "spending": { + "this_month": { + "JEDI_CLIN_1": 777, + "JEDI_CLIN_2": 850 + }, + "last_month": { + "JEDI_CLIN_1": 584, + "JEDI_CLIN_2": 362 + }, + "total": { + "JEDI_CLIN_1": 44505, + "JEDI_CLIN_2": 21378 + } + } + }, + { + "name": "PreProd", + "spending": { + "this_month": { + "JEDI_CLIN_1": 487, + "JEDI_CLIN_2": 733 + }, + "last_month": { + "JEDI_CLIN_1": 542, + "JEDI_CLIN_2": 999 + }, + "total": { + "JEDI_CLIN_1": 8713, + "JEDI_CLIN_2": 10586 + } + } + }, + { + "name": "Prod", + "spending": { + "this_month": { + "JEDI_CLIN_1": 420, + "JEDI_CLIN_2": 503 + }, + "last_month": { + "JEDI_CLIN_1": 756, + "JEDI_CLIN_2": 941 + }, + "total": { + "JEDI_CLIN_1": 43003, + "JEDI_CLIN_2": 20601 + } + } + } + ] + }, + { + "name": "Canton", + "environments": [ + { + "name": "Prod", + "spending": { + "this_month": { + "JEDI_CLIN_1": 661, + "JEDI_CLIN_2": 599 + }, + "last_month": { + "JEDI_CLIN_1": 962, + "JEDI_CLIN_2": 383 + }, + "total": { + "JEDI_CLIN_1": 24501, + "JEDI_CLIN_2": 7551 + } + } + } + ] + }, + { + "name": "BD04", + "environments": [ + { + "name": "Integ", + "spending": { + "this_month": { + "JEDI_CLIN_1": 790, + "JEDI_CLIN_2": 513 + }, + "last_month": { + "JEDI_CLIN_1": 886, + "JEDI_CLIN_2": 991 + }, + "total": { + "JEDI_CLIN_1": 43684, + "JEDI_CLIN_2": 40196 + } + } + }, + { + "name": "PreProd", + "spending": { + "this_month": { + "JEDI_CLIN_1": 513, + "JEDI_CLIN_2": 706 + }, + "last_month": { + "JEDI_CLIN_1": 945, + "JEDI_CLIN_2": 380 + }, + "total": { + "JEDI_CLIN_1": 28189, + "JEDI_CLIN_2": 9759 + } + } + } + ] + }, + { + "name": "SCV18", + "environments": [ + { + "name": "Dev", + "spending": { + "this_month": { + "JEDI_CLIN_1": 933, + "JEDI_CLIN_2": 993 + }, + "last_month": { + "JEDI_CLIN_1": 319, + "JEDI_CLIN_2": 619 + }, + "total": { + "JEDI_CLIN_1": 40585, + "JEDI_CLIN_2": 28872 + } + } + } + ] + }, + { + "name": "Crown", + "environments": [ + { + "name": "CR Portal Dev", + "spending": { + "this_month": { + "JEDI_CLIN_1": 711, + "JEDI_CLIN_2": 413 + }, + "last_month": { + "JEDI_CLIN_1": 908, + "JEDI_CLIN_2": 632 + }, + "total": { + "JEDI_CLIN_1": 18753, + "JEDI_CLIN_2": 4004 + } + } + }, + { + "name": "CR Staging", + "spending": { + "this_month": { + "JEDI_CLIN_1": 440, + "JEDI_CLIN_2": 918 + }, + "last_month": { + "JEDI_CLIN_1": 370, + "JEDI_CLIN_2": 472 + }, + "total": { + "JEDI_CLIN_1": 40602, + "JEDI_CLIN_2": 6834 + } + } + }, + { + "name": "CR Portal Test 1", + "spending": { + "this_month": { + "JEDI_CLIN_1": 928, + "JEDI_CLIN_2": 796 + }, + "last_month": { + "JEDI_CLIN_1": 680, + "JEDI_CLIN_2": 312 + }, + "total": { + "JEDI_CLIN_1": 36058, + "JEDI_CLIN_2": 42375 + } + } + }, + { + "name": "Jewels Prod", + "spending": { + "this_month": { + "JEDI_CLIN_1": 304, + "JEDI_CLIN_2": 428 + }, + "last_month": { + "JEDI_CLIN_1": 898, + "JEDI_CLIN_2": 729 + }, + "total": { + "JEDI_CLIN_1": 3162, + "JEDI_CLIN_2": 49836 + } + } + }, + { + "name": "Jewels Dev", + "spending": { + "this_month": { + "JEDI_CLIN_1": 498, + "JEDI_CLIN_2": 890 + }, + "last_month": { + "JEDI_CLIN_1": 506, + "JEDI_CLIN_2": 659 + }, + "total": { + "JEDI_CLIN_1": 6248, + "JEDI_CLIN_2": 3866 + } + } + } + ] + } + ] + }, + "B-Wing": { + "applications": [ + { + "name": "NP02", + "environments": [ + { + "name": "Integ", + "spending": { + "this_month": { + "JEDI_CLIN_1": 455, + "JEDI_CLIN_2": 746 + }, + "last_month": { + "JEDI_CLIN_1": 973, + "JEDI_CLIN_2": 504 + }, + "total": { + "JEDI_CLIN_1": 11493, + "JEDI_CLIN_2": 17751 + } + } + }, + { + "name": "PreProd", + "spending": { + "this_month": { + "JEDI_CLIN_1": 582, + "JEDI_CLIN_2": 339 + }, + "last_month": { + "JEDI_CLIN_1": 392, + "JEDI_CLIN_2": 885 + }, + "total": { + "JEDI_CLIN_1": 41856, + "JEDI_CLIN_2": 46399 + } + } + }, + { + "name": "Prod", + "spending": { + "this_month": { + "JEDI_CLIN_1": 446, + "JEDI_CLIN_2": 670 + }, + "last_month": { + "JEDI_CLIN_1": 368, + "JEDI_CLIN_2": 963 + }, + "total": { + "JEDI_CLIN_1": 10030, + "JEDI_CLIN_2": 29253 + } + } + } + ] + }, + { + "name": "FM", + "environments": [ + { + "name": "Integ", + "spending": { + "this_month": { + "JEDI_CLIN_1": 994, + "JEDI_CLIN_2": 573 + }, + "last_month": { + "JEDI_CLIN_1": 699, + "JEDI_CLIN_2": 418 + }, + "total": { + "JEDI_CLIN_1": 27881, + "JEDI_CLIN_2": 37092 + } + } + }, + { + "name": "Prod", + "spending": { + "this_month": { + "JEDI_CLIN_1": 838, + "JEDI_CLIN_2": 839 + }, + "last_month": { + "JEDI_CLIN_1": 775, + "JEDI_CLIN_2": 946 + }, + "total": { + "JEDI_CLIN_1": 45007, + "JEDI_CLIN_2": 16197 + } + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/js/components/__tests__/multi_checkbox_input.test.js b/js/components/__tests__/multi_checkbox_input.test.js new file mode 100644 index 00000000..8e012db4 --- /dev/null +++ b/js/components/__tests__/multi_checkbox_input.test.js @@ -0,0 +1,102 @@ +import { mount } from '@vue/test-utils' +import multicheckboxinput from '../multi_checkbox_input' +import { makeTestWrapper } from '../../test_utils/component_test_helpers' + +const WrapperComponent = makeTestWrapper({ + components: { + multicheckboxinput, + }, + templatePath: 'multi_checkbox_input_template.html', + data: function() { + const { initialvalue, optional } = this.initialData + return { initialvalue, optional } + }, +}) + +describe('MultiCheckboxInput Renders Correctly', () => { + it('Should initialize unchecked and with no validation showing', () => { + const wrapper = mount(WrapperComponent, { + propsData: { + name: 'testCheck', + initialData: { + initialvalue: [], + }, + }, + }) + expect(wrapper.contains('.usa-input--success')).toBe(false) + expect(wrapper.contains('.usa-input--error')).toBe(false) + expect(wrapper.find('.usa-input input[value="a"]').element.checked).toBe( + false + ) + expect(wrapper.find('.usa-input input[value="b"]').element.checked).toBe( + false + ) + }) + + it('Should initialize with "a" checked', () => { + const wrapper = mount(WrapperComponent, { + propsData: { + name: 'testCheck', + initialData: { + initialvalue: ['a'], + }, + }, + }) + expect(wrapper.find('.usa-input input[value="a"]').element.checked).toBe( + true + ) + expect(wrapper.find('.usa-input input[value="b"]').element.checked).toBe( + false + ) + }) +}) + +describe('Multicheckbox shows validation states correctly', () => { + it('Should be valid when any checkbox is clicked', () => { + const wrapper = mount(WrapperComponent, { + propsData: { + name: 'testCheck', + initialData: { initialvalue: [] }, + }, + }) + wrapper.find('.usa-input input[value="a"]').setChecked() + expect(wrapper.contains('.usa-input--success')).toBe(true) + expect(wrapper.contains('.usa-input--error')).toBe(false) + }) + + it('Should be invalid when no checkboxes are checked', () => { + const wrapper = mount(WrapperComponent, { + propsData: { + name: 'testCheck', + initialData: { + initialvalue: [], + }, + }, + }) + + // Check and then uncheck a checkbox + const checkboxA = wrapper.find('.usa-input input[value="a"]') + checkboxA.setChecked() + checkboxA.setChecked(false) + + expect(wrapper.contains('.usa-input--error')).toBe(true) + expect(wrapper.contains('.usa-input--success')).toBe(false) + }) + + it('Should be valid when no checkboxes are checked but it is optional', () => { + const wrapper = mount(WrapperComponent, { + propsData: { + name: 'testCheck', + initialData: { initialvalue: [], optional: true }, + }, + }) + + // Check and then uncheck a checkbox + const checkboxA = wrapper.find('.usa-input input[value="a"]') + checkboxA.setChecked() + checkboxA.setChecked(false) + + expect(wrapper.contains('.usa-input--error')).toBe(false) + expect(wrapper.contains('.usa-input--success')).toBe(true) + }) +}) diff --git a/js/components/accordion.js b/js/components/accordion.js index d281a9e7..b96e1b80 100644 --- a/js/components/accordion.js +++ b/js/components/accordion.js @@ -11,4 +11,10 @@ export default { default: false, }, }, + + methods: { + collapse: function() { + this.isVisible = false + }, + }, } diff --git a/js/components/accordion_list.js b/js/components/accordion_list.js new file mode 100644 index 00000000..41e11d33 --- /dev/null +++ b/js/components/accordion_list.js @@ -0,0 +1,16 @@ +import Accordion from './accordion' + +export default { + name: 'accordion-list', + + components: { + Accordion, + }, + + methods: { + handleClick: function(e) { + e.preventDefault() + this.$children.forEach(el => el.collapse()) + }, + }, +} diff --git a/js/components/multi_checkbox_input.js b/js/components/multi_checkbox_input.js index fdba839f..f2d878da 100644 --- a/js/components/multi_checkbox_input.js +++ b/js/components/multi_checkbox_input.js @@ -13,22 +13,14 @@ export default { type: Array, default: () => [], }, - initialOtherValue: String, optional: Boolean, }, data: function() { - const showError = (this.initialErrors && this.initialErrors.length) || false return { - showError: showError, - showValid: !showError && this.initialValue.length > 0, + showError: this.initialErrors.length > 0, + showValid: false, validationError: this.initialErrors.join(' '), - otherChecked: this.initialValue.includes('other') - ? true - : this.otherChecked, - otherText: this.initialValue.includes('other') - ? this.initialOtherValue - : '', selections: this.initialValue, } }, @@ -36,17 +28,15 @@ export default { methods: { onInput: function(e) { emitFieldChange(this) - this.showError = false - this.showValid = true - }, - otherToggle: function() { - this.otherChecked = !this.otherChecked + this.showError = !this.valid + this.showValid = !this.showError + this.validationError = 'This field is required.' }, }, computed: { valid: function() { - return this.optional || this.showValid + return this.optional || this.selections.length > 0 }, }, } diff --git a/js/components/tables/spend_table.js b/js/components/tables/spend_table.js index c3a1c90f..bd5f5a01 100644 --- a/js/components/tables/spend_table.js +++ b/js/components/tables/spend_table.js @@ -1,14 +1,12 @@ import { set } from 'vue/dist/vue' import { formatDollars } from '../../lib/dollars' +import { set as _set } from 'lodash' export default { name: 'spend-table', props: { - applications: Object, - environments: Object, - currentMonthIndex: String, - prevMonthIndex: String, + applications: Array, }, data: function() { @@ -18,20 +16,16 @@ export default { }, created: function() { - Object.keys(this.applications).forEach(application => { - set(this.applicationsState[application], 'isVisible', false) + this.applicationsState.forEach(application => { + application.isVisible = false }) }, methods: { - toggle: function(e, applicationName) { - this.applicationsState = Object.assign(this.applicationsState, { - [applicationName]: Object.assign( - this.applicationsState[applicationName], - { - isVisible: !this.applicationsState[applicationName].isVisible, - } - ), + toggle: function(e, applicationIndex) { + set(this.applicationsState, applicationIndex, { + ...this.applicationsState[applicationIndex], + isVisible: !this.applicationsState[applicationIndex].isVisible, }) }, diff --git a/js/components/upload_input.js b/js/components/upload_input.js index 52cd8bf8..cdd9b15e 100644 --- a/js/components/upload_input.js +++ b/js/components/upload_input.js @@ -58,18 +58,18 @@ export default { this.$refs.attachmentFilename.value = file.name this.$refs.attachmentObjectName.value = response.objectName this.$refs.attachmentInput.disabled = true + emitFieldChange(this) + this.changed = true this.downloadLink = await this.getDownloadLink( file.name, response.objectName ) } else { + emitFieldChange(this) + this.changed = true this.uploadError = true } - - this.changed = true - - emitFieldChange(this) }, removeAttachment: function(e) { e.preventDefault() diff --git a/js/index.js b/js/index.js index a28c4868..fb5cdd6e 100644 --- a/js/index.js +++ b/js/index.js @@ -7,6 +7,8 @@ import Vue from 'vue/dist/vue' import VTooltip from 'v-tooltip' import stickybits from 'stickybits' +import Accordion from './components/accordion' +import AccordionList from './components/accordion_list' import dodlogin from './components/dodlogin' import optionsinput from './components/options_input' import multicheckboxinput from './components/multi_checkbox_input' @@ -29,7 +31,6 @@ import SemiCollapsibleText from './components/semi_collapsible_text' import ToForm from './components/forms/to_form' import ClinFields from './components/clin_fields' import PopDateRange from './components/pop_date_range' -import Accordion from './components/accordion' import ToggleMenu from './components/toggle_menu' Vue.config.productionTip = false @@ -42,6 +43,7 @@ const app = new Vue({ el: '#app-root', components: { Accordion, + AccordionList, dodlogin, toggler, optionsinput, diff --git a/js/mixins/toggle.js b/js/mixins/toggle.js index d891eb02..3e155dd3 100644 --- a/js/mixins/toggle.js +++ b/js/mixins/toggle.js @@ -17,6 +17,7 @@ export default { methods: { toggle: function(e) { e.preventDefault() + e.stopPropagation() this.isVisible = !this.isVisible }, }, diff --git a/load-test/locustfile.py b/load-test/locustfile.py index 0ed2bd63..15b89db6 100644 --- a/load-test/locustfile.py +++ b/load-test/locustfile.py @@ -15,7 +15,7 @@ PASSWORD = os.getenv("ATAT_BA_PASSWORD", "") DISABLE_VERIFY = os.getenv("DISABLE_VERIFY", "true").lower() == "true" # Alpha numerics for random entity names -LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" +LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" #pragma: allowlist secret NEW_PORTFOLIO_CHANCE = 10 NEW_APPLICATION_CHANCE = 10 @@ -141,15 +141,8 @@ def create_portfolio(l): new_portfolio_form = l.client.get("/portfolios/new") new_portfolio_body = { "name": f"Load Test Created - {''.join(choices(LETTERS, k=5))}", - "defense_component": "Army, Department of the", + "defense_component": "army", "description": "Test", - "app_migration": "none", - "native_apps": "yes", - "complexity": "storage", - "complexity_other": "", - "dev_team": "civilians", - "dev_team_other": "", - "team_experience": "none", "csrf_token": get_csrf_token(new_portfolio_form), } diff --git a/script/integration_tests b/script/integration_tests new file mode 100755 index 00000000..e84b103a --- /dev/null +++ b/script/integration_tests @@ -0,0 +1,94 @@ +#!/bin/bash + +# script/integration_tests: Run the integration tests via docker. +set -e + +if [ -z "${CONTAINER_TIMEOUT+is_set}" ]; then + CONTAINER_TIMEOUT=200 +fi + +# Expected settings. Script will error if these are not provided. +SETTINGS=( + CONTAINER_IMAGE + NGROK_TOKEN + GI_API_KEY + GI_SUITE +) + +# Loop all expected settings. Track ones that are missing. If any +# are missing, exit. +MISSING_SETTINGS=() +for envvar in "${SETTINGS[@]}"; do + if [ -z "${!envvar}" ]; then + MISSING_SETTINGS+=(${envvar}) + fi +done + +if [[ ${#MISSING_SETTINGS[@]} > 0 ]]; then + >&2 echo "The following variables need to be set:" + for missing in "${MISSING_SETTINGS[@]}"; do + >&2 echo $missing + done + exit 1 +fi + +# Remove any existing container and network instances +docker container stop redis postgres test-atat || true && docker container rm redis postgres test-atat || true +docker network rm atat || true + +# Create network +docker network create atat + +# Start Redis and Postgres +docker run -d --network atat --link redis:redis -p 6379:6379 --name redis circleci/redis:4-alpine3.8 +docker run -d --network atat --link postgres:postgres -p 5432:5432 --name postgres circleci/postgres:10-alpine-ram +# Wait for datastores to be available +sleep 3 + +# Create database and run migrations +docker exec postgres createdb -U postgres atat +docker run --network atat -e PGDATABASE=atat -e PGHOST=postgres -e REDIS_HOST=redis:6379 $CONTAINER_IMAGE .venv/bin/python .venv/bin/alembic upgrade head +docker run --network atat -e PGDATABASE=atat -e PGHOST=postgres -e REDIS_HOST=redis:6379 $CONTAINER_IMAGE .venv/bin/python script/seed_roles.py + +# Start application container +docker run -d \ +-e DISABLE_CRL_CHECK=true \ +-e PGHOST=postgres \ +-e REDIS_HOST=redis:6379 \ +-p 8000:8000 \ +--network atat \ +--name test-atat \ +$CONTAINER_IMAGE \ +/bin/sh -c " + echo CLOUD_PROVIDER=mock > .env &&\ + yarn build &&\ + uwsgi \ + --callable app \ + --module app \ + --plugin python3 \ + --virtualenv /install/.venv \ + --http-socket :8000 +" + +# Use curl to wait for application container to become available +docker pull curlimages/curl:latest +docker run --network atat \ + curlimages/curl:latest \ + curl --connect-timeout 3 \ + --max-time 5 \ + --retry $CONTAINER_TIMEOUT \ + --retry-connrefused \ + --retry-delay 1 \ + --retry-max-time $CONTAINER_TIMEOUT \ + test-atat:8000 + +# Run Ghost Inspector tests +docker pull ghostinspector/test-runner-standalone:latest +docker run \ + -e NGROK_TOKEN=$NGROK_TOKEN \ + -e GI_API_KEY=$GI_API_KEY \ + -e GI_SUITE=$GI_SUITE \ + -e GI_PARAMS_JSON='{}' \ + -e APP_PORT="test-atat:8000" \ + --network atat \ + ghostinspector/test-runner-standalone:latest diff --git a/script/k8s_config b/script/k8s_config index ee3c9878..b489c942 100755 --- a/script/k8s_config +++ b/script/k8s_config @@ -13,6 +13,7 @@ SETTINGS=( AUTH_DOMAIN KV_MI_ID KV_MI_CLIENT_ID + TENANT_ID ) # Loop all expected settings. Track ones that are missing and build diff --git a/script/seed_sample.py b/script/seed_sample.py index 11a45530..9f64f049 100644 --- a/script/seed_sample.py +++ b/script/seed_sample.py @@ -14,6 +14,7 @@ from atst.app import make_config, make_app from atst.database import db from atst.models.application import Application +from atst.models.clin import JEDICLINType from atst.models.environment_role import CSPRole from atst.domain.application_roles import ApplicationRoles @@ -30,6 +31,8 @@ from atst.domain.users import Users from atst.routes.dev import _DEV_USERS as DEV_USERS +from atst.utils import pick + from tests.factories import ( random_service_branch, TaskOrderFactory, @@ -197,7 +200,22 @@ def add_task_orders_to_portfolio(portfolio): CLINFactory.build( task_order=expired_to, start_date=(today - five_days), end_date=yesterday ), - CLINFactory.build(task_order=active_to, start_date=yesterday, end_date=future), + CLINFactory.build( + task_order=active_to, + start_date=yesterday, + end_date=future, + total_amount=1_000_000, + obligated_amount=500_000, + jedi_clin_type=JEDICLINType.JEDI_CLIN_1, + ), + CLINFactory.build( + task_order=active_to, + start_date=yesterday, + end_date=future, + total_amount=500_000, + obligated_amount=200_000, + jedi_clin_type=JEDICLINType.JEDI_CLIN_2, + ), ] task_orders = [draft_to, unsigned_to, upcoming_to, expired_to, active_to] @@ -238,6 +256,7 @@ def add_applications_to_portfolio(portfolio): None, first_name=user_data["first_name"], last_name=user_data["last_name"], + email=user_data["email"], ) app_role = ApplicationRoles.create( @@ -263,7 +282,23 @@ def add_applications_to_portfolio(portfolio): def create_demo_portfolio(name, data): try: - portfolio_owner = Users.get_or_create_by_dod_id("2345678901") # Amanda + portfolio_owner = Users.get_or_create_by_dod_id( + "2345678901", + **pick( + [ + "permission_sets", + "first_name", + "last_name", + "email", + "service_branch", + "phone_number", + "citizenship", + "designation", + "date_latest_training", + ], + DEV_USERS["amanda"], + ), + ) # Amanda # auditor = Users.get_by_dod_id("3453453453") # Sally except NotFoundError: print( @@ -281,9 +316,9 @@ def create_demo_portfolio(name, data): for mock_application in data["applications"]: application = Application( - portfolio=portfolio, name=mock_application.name, description="" + portfolio=portfolio, name=mock_application["name"], description="" ) - env_names = [env.name for env in mock_application.environments] + env_names = [env["name"] for env in mock_application["environments"]] envs = Environments.create_many(portfolio.owner, application, env_names) db.session.add(application) db.session.commit() @@ -294,8 +329,8 @@ def seed_db(): amanda = Users.get_by_dod_id("2345678901") # Create Portfolios for Amanda with mocked reporting data - create_demo_portfolio("A-Wing", MockReportingProvider.REPORT_FIXTURE_MAP["A-Wing"]) - create_demo_portfolio("B-Wing", MockReportingProvider.REPORT_FIXTURE_MAP["B-Wing"]) + create_demo_portfolio("A-Wing", MockReportingProvider.FIXTURE_SPEND_DATA["A-Wing"]) + create_demo_portfolio("B-Wing", MockReportingProvider.FIXTURE_SPEND_DATA["B-Wing"]) tie_interceptor = Portfolios.create( user=amanda, diff --git a/styles/atat.scss b/styles/atat.scss index c3fd1a55..4c8aa263 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -45,11 +45,3 @@ @import "sections/application_edit"; @import "sections/reports"; @import "sections/task_order"; - -// -// IE likes to display an outline when focusing on an element. This -// fix removes that unwanted outline on focus. -// -*:focus { - outline: 0; -} diff --git a/styles/components/_empty_state.scss b/styles/components/_empty_state.scss index 0e102a4d..12405e05 100644 --- a/styles/components/_empty_state.scss +++ b/styles/components/_empty_state.scss @@ -1,28 +1,20 @@ .empty-state { - text-align: center; - padding: 5rem ($gap * 2) 2rem; - display: flex; - flex-direction: column; - align-items: center; + padding: $gap * 3; max-width: 100%; + background-color: $color-gray-lightest; + margin-top: $gap * 5; - > .icon { - @include icon-size(50); - @include icon-color($color-gray-light); + hr { + margin-left: -$gap * 3; + margin-right: -$gap * 3; } - &__message { - font-weight: $font-bold; - } + &__footer { + text-align: center; - &__sub-message { - @include h4; - - color: $color-gray; - max-width: 100%; - - @include media($large-screen) { - @include h3; + a.usa-button { + width: 60%; + display: inline-block; } } } diff --git a/styles/components/_forms.scss b/styles/components/_forms.scss index 19ca2fb5..a6e0709a 100644 --- a/styles/components/_forms.scss +++ b/styles/components/_forms.scss @@ -1,6 +1,9 @@ // Form Grid .form-row { margin: ($gap * 4) 0; + &--separated { + border-bottom: $color-gray-lighter 1px solid; + } .form-col { flex-grow: 1; diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 0e018809..0da98f17 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -383,6 +383,8 @@ } .portfolio-applications { + margin-top: $gap * 5; + &__header { &--title { @include subheading; diff --git a/styles/components/_sticky_cta.scss b/styles/components/_sticky_cta.scss index c5946951..ce05144f 100644 --- a/styles/components/_sticky_cta.scss +++ b/styles/components/_sticky_cta.scss @@ -1,7 +1,7 @@ .sticky-cta { - margin-left: -$gap * 4; + margin-left: -$gap * 5; margin-right: -$gap * 5; - z-index: 10; + z-index: 1; background-color: $color-gray-lightest; border-top: 1px solid $color-gray-lighter; border-bottom: 1px solid $color-gray-lighter; diff --git a/styles/core/_grid.scss b/styles/core/_grid.scss index ff07cca6..d060198d 100644 --- a/styles/core/_grid.scss +++ b/styles/core/_grid.scss @@ -40,8 +40,7 @@ } &.col--grow { - flex: 1; - flex-grow: 1; + flex: 1 auto; padding-right: $spacing-small; } diff --git a/styles/core/_util.scss b/styles/core/_util.scss index b719d855..ff6e8e3f 100644 --- a/styles/core/_util.scss +++ b/styles/core/_util.scss @@ -73,6 +73,10 @@ color: $color-green; } +.text-danger { + color: $color-secondary; +} + .user-permission { font-weight: $font-normal; } diff --git a/styles/core/_variables.scss b/styles/core/_variables.scss index dc609fe4..fe81b498 100644 --- a/styles/core/_variables.scss +++ b/styles/core/_variables.scss @@ -16,6 +16,7 @@ $footer-height: 5rem; $usa-banner-height: 2.8rem; $sidenav-expanded-width: 25rem; $sidenav-collapsed-width: 10rem; +$max-panel-width: 80rem; /* * USWDS Variables diff --git a/styles/elements/_accordions.scss b/styles/elements/_accordions.scss index b81a7c6e..8d21ef62 100644 --- a/styles/elements/_accordions.scss +++ b/styles/elements/_accordions.scss @@ -1,148 +1,65 @@ -.triangle-box { - position: relative; - - .triangle-up { - $triangle-size: $gap * 1.5; - - position: absolute; - width: 0; - height: 0; - border-left: $triangle-size solid transparent; - border-right: $triangle-size solid transparent; - border-bottom: $triangle-size solid $color-blue-light; - bottom: -4px; - right: 50%; - } -} - .accordion { - @include block-list; - - box-shadow: 0 4px 10px 0 rgba(193, 193, 193, 0.5); - margin-bottom: 6 * $gap; - - .icon-link { - margin: (-$gap) 0; - } - - .icon-link, - .label { - &:first-child { - margin-left: -$gap; - } - - &:last-child { - margin-right: -$gap; - } - } + @include shadow-panel; + margin: $gap * 3 0; + max-width: $max-panel-width; &__header { - @include block-list-header; + padding: $gap * 2 $gap * 3; + background-color: $color-white; - border-top: 3px solid $color-blue; - border-bottom: none; - box-shadow: 0 2px 4px 0 rgba(216, 218, 222, 0.58); - - &.row { - background: $color-white; - } - } - - &__title { - @include block-list__title; - - color: $color-blue; - - @include h3; - - &.icon-link { + &-text { margin: 0; - display: block; - padding: 0 $gap; - text-decoration: none; } } - &__description { - @include block-list__description; - - font-style: italic; - font-size: $small-font-size; - color: $color-gray; + &__button { + margin: 0; } - &__actions { - margin-top: $gap; - margin-bottom: $gap * 0.5; - display: flex; - flex-direction: row; + &__content { + padding: 0 ($gap * 3) $gap; - .icon-link { - font-size: $small-font-size; + &--list-item { + border-bottom: 1px solid $color-gray-lightest; + padding: $gap 0; - svg { - width: 1rem; + &:last-child { + border-bottom: none; + padding-bottom: $gap; + } + + .col { + padding-right: $gap * 2; + + &:last-child { + padding-right: 0; + } + } + + h4 { + margin: $gap * 2 0 $gap; + } + + h5 { + font-size: 1rem; + color: $color-gray; + margin: 0; } } - &__footer { - @include block-list__footer; - - border-top: 0; + &--empty { + font-weight: $font-bold; + color: $color-gray-dark; + padding: $gap * 8; + text-align: center; } } - &__item { - @include block-list-item; + &-list { + max-width: $max-panel-width; - opacity: 0.75; - background-color: $color-blue-light; - border-bottom: 1px solid rgba($color-gray-light, 0.5); - - &--selectable { - > div { - display: flex; - flex-direction: row-reverse; - - @include ie-only { - width: 100%; - } - - > label { - @include block-list-selectable-label; - } - } - - > label { - @include block-list-selectable-label; - } - - input:checked { - + label { - color: $color-primary; - } - } - - @include ie-only { - dl { - width: 100%; - padding-left: $gap * 4; - } - } + &__collapse { + cursor: pointer; } } - - .counter { - background-color: $color-cool-blue-light; - color: $color-white; - border-radius: 2px; - padding: ($gap / 2) $gap; - margin-left: $gap; - } - - .separator { - border: 1px solid $color-gray-medium; - opacity: 0.75; - margin: 0 (0.5 * $gap); - } } diff --git a/styles/elements/_icons.scss b/styles/elements/_icons.scss index 13e958b1..c7a43414 100644 --- a/styles/elements/_icons.scss +++ b/styles/elements/_icons.scss @@ -90,4 +90,8 @@ padding: 2px; } } + + &--primary { + @include icon-color($color-primary); + } } diff --git a/styles/sections/_reports.scss b/styles/sections/_reports.scss index f80a7750..ee43ffa2 100644 --- a/styles/sections/_reports.scss +++ b/styles/sections/_reports.scss @@ -16,6 +16,16 @@ } .jedi-clin-funding { + $insufficient-gradient: repeating-linear-gradient( + 45deg, + $color-secondary-dark, + $color-secondary-dark 10px, + $color-secondary-darkest 11px, + $color-secondary-darkest 14px + ); + + $graph-bar-height: 2rem; + padding-top: $gap * 3; padding-bottom: $gap * 3; @@ -37,14 +47,36 @@ margin: 0; } - &__meter { - margin: 10px 0; - -moz-transform: scale(-1, 1); - -webkit-transform: scale(-1, 1); - -o-transform: scale(-1, 1); - -ms-transform: scale(-1, 1); - transform: scale(-1, 1); + &__graph { width: 100%; + height: $graph-bar-height; + margin-top: $gap * 2; + margin-bottom: $gap * 2; + display: flex; + + &-bar { + height: 100%; + display: block; + float: left; + margin-right: $gap / 2; + + &:last-child { + margin-right: 0; + } + + &--invoiced { + background: $color-green; + } + &--estimated { + background: $color-green-lighter; + } + &--remaining { + background: $color-primary-darkest; + } + &--insufficient { + background: $insufficient-gradient; + } + } &-values { display: flex; @@ -52,13 +84,32 @@ } &__meta { - &--remaining { - margin-left: auto; - text-align: right; - } + margin-right: $gap * 5; + &-header { @include small-copy; margin-bottom: 0; + display: flex; + align-items: center; + } + + &-key { + height: $graph-bar-height; + width: $graph-bar-height; + margin-right: $gap / 2; + + &--invoiced { + background: $color-green; + } + &--estimated { + background: $color-green-lighter; + } + &--remaining { + background: $color-primary-darkest; + } + &--insufficient { + background: $insufficient-gradient; + } } &-value { margin-bottom: 0; @@ -87,4 +138,32 @@ font-size: $lead-font-size; } } + + .reporting-expended-funding { + &__header { + margin: 0; + } + &__content { + padding: 0; + border-top: 1px solid $color-gray-lighter; + } + } + + .reporting-spend-table { + &__env-row { + &-label { + margin-left: $gap * 5; + } + &--last { + & > td { + border-bottom: 1px solid black; + } + &:last-of-type { + & > td { + border-bottom: none; + } + } + } + } + } } diff --git a/styles/sections/_task_order.scss b/styles/sections/_task_order.scss index 26bb4c79..228bf126 100644 --- a/styles/sections/_task_order.scss +++ b/styles/sections/_task_order.scss @@ -1,25 +1,3 @@ -.task-order-list { - margin-top: 6 * $gap; -} - -.task-order-card { - &__buttons .usa-button { - min-width: 10rem; - } - - &__buttons .usa-button-secondary { - min-width: 14rem; - } - - .label { - font-size: $small-font-size; - margin-right: 2 * $gap; - min-width: 7rem; - display: flex; - justify-content: space-around; - } -} - .task-order { margin-top: $gap * 4; width: 900px; @@ -149,21 +127,6 @@ width: 100%; } - .label { - &--pending, - &--started { - background-color: $color-gold; - } - - &--active { - background-color: $color-green; - } - - &--expired { - background-color: $color-red; - } - } - .task-order-document-link { &__icon { padding-top: 0.5rem; diff --git a/templates/applications/index.html b/templates/applications/index.html index bc7ae431..00a9b0e8 100644 --- a/templates/applications/index.html +++ b/templates/applications/index.html @@ -1,5 +1,8 @@ -{% from "components/icon.html" import Icon %} +{% from "components/accordion.html" import Accordion %} +{% from "components/accordion_list.html" import AccordionList %} {% from "components/empty_state.html" import EmptyState %} +{% from "components/sticky_cta.html" import StickyCTA %} +{% from "components/icon.html" import Icon %} {% extends "portfolios/base.html" %} @@ -7,85 +10,74 @@ {% block portfolio_content %} +{% call StickyCTA(text="common.applications"|translate) %} + {% if can_create_applications and portfolio.applications %} + + {{ "portfolios.applications.create_button"|translate }} + + {% endif %} +{% endcall %} +
{% include "fragments/flash.html" %} - {% if not portfolio.applications %} {{ EmptyState( - 'This portfolio doesn’t have any applications', - action_label='Add a new application' if can_create_applications else None, - action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None, - icon='cloud', - sub_message=None if can_create_applications else 'Please contact your JEDI Cloud portfolio administrator to set up a new application.', - add_perms=can_create_applications + header="portfolios.applications.empty_state.header"|translate, + message="portfolios.applications.empty_state.message"|translate, + button_text="portfolios.applications.empty_state.button_text"|translate, + button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id), + view_only_text="portfolios.applications.empty_state.view_only_text"|translate, + user_can_create=can_create_applications, ) }} {% else %} - -
+ {% call AccordionList() %} {% for application in portfolio.applications|sort(attribute='name') %} {% set section_name = "application-{}".format(application.id) %} - - -
-
-
- -

- {{ application.description }} -

-
- - {{ "portfolios.applications.app_settings_text" | translate }} - -
- {% set has_environments = 0 < (application.environments|length) %} - - Environments ({{ application.environments|length }}) - {% if has_environments %} - - {{ Icon('caret_up') }} - - - {{ Icon('caret_down') }} - -
+ {% set title = "Environments ({})".format(application.environments|length) %} +
+ + {% call Accordion( + title=title, + id=section_name, + heading_tag="h4" + ) %} + {% for environment in application.environments %} + {% set env_access = environment_access[environment.id] %} +
+
+
+ {% if env_access %} + + {{ environment.displayname }} {{ Icon('link', classes='icon--medium icon--primary') }} + + {% else %} + {{ environment.displayname }} {% endif %} - +
+ {% if env_access %} +
+ {{ env_access }} +
+ {% endif %}
-
- -
-
+ {% endfor %} + {% endcall %} +
{% endfor %} -
- + {% endcall %} {% endif %} diff --git a/templates/components/accordion.html b/templates/components/accordion.html index 8e508321..9ec92099 100644 --- a/templates/components/accordion.html +++ b/templates/components/accordion.html @@ -1,23 +1,30 @@ -{% macro Accordion(title, id, heading_level="h2") %} - -
- <{{heading_level}}> - - -
- {{ caller() }} -
-
-
-{% endmacro %} \ No newline at end of file +{% macro Accordion( + title, + id, + wrapper_tag="div", + wrapper_classes="", + heading_tag="h2", + heading_classes="", + content_tag="div", + content_classes="") %} + + <{{wrapper_tag}} class="{{ wrapper_classes }}"> + <{{heading_tag}} class="accordion__button {{ heading_classes }}"> + + + <{{content_tag}} + id="{{ id }}" + class="usa-accordion-content accordion__content {{ content_classes }}" + v-bind:aria-hidden="isVisible ? 'false' : 'true'"> + {{ caller() }} + + + +{% endmacro %} diff --git a/templates/components/accordion_list.html b/templates/components/accordion_list.html new file mode 100644 index 00000000..216e51b1 --- /dev/null +++ b/templates/components/accordion_list.html @@ -0,0 +1,11 @@ +{% macro AccordionList() %} + +
+ + + {{ caller() }} +
+
+{% endmacro %} diff --git a/templates/components/empty_state.html b/templates/components/empty_state.html index a9d9ff5e..9989e4f8 100644 --- a/templates/components/empty_state.html +++ b/templates/components/empty_state.html @@ -1,20 +1,14 @@ -{% from "components/icon.html" import Icon %} - -{% macro EmptyState(message, action_label, action_href, icon=None, sub_message=None, add_perms=True) -%} -
-

{{ message }}

- - {% if icon %} - {{ Icon(icon) }} - {% endif %} - - {% if sub_message %} -

{{ sub_message }}

- {% endif %} - - {% if add_perms and (action_href and action_label) %} - {{ action_label }} - {% endif %} - +{% macro EmptyState(header, message, button_text, button_link, view_only_text, user_can_create=True) %} +
+

{{ header }}

+

{{ message }}

+
+
-{%- endmacro %} +{% endmacro %} diff --git a/templates/components/multi_checkbox_input.html b/templates/components/multi_checkbox_input.html index 2f2c65ec..6de6331b 100644 --- a/templates/components/multi_checkbox_input.html +++ b/templates/components/multi_checkbox_input.html @@ -45,19 +45,8 @@
    {% for choice in field.choices %}
  • - {% if choice[0] != 'other' %} - {% else %} - - - - {% if other_input_field %} -
    - -
    - {% endif %} - {% endif %}
  • {% endfor %}
diff --git a/templates/home.html b/templates/home.html index 91774138..4e459639 100644 --- a/templates/home.html +++ b/templates/home.html @@ -14,7 +14,7 @@ {% endif %} {% call StickyCTA(sticky_header) %} - + {{ "home.add_portfolio_button_text" | translate }} {% endcall %} diff --git a/templates/portfolios/blank_slate.html b/templates/portfolios/blank_slate.html deleted file mode 100644 index 62c59aa5..00000000 --- a/templates/portfolios/blank_slate.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base.html" %} - -{% from "components/empty_state.html" import EmptyState %} -{% from "components/tooltip.html" import Tooltip %} - -{% block content %} - {{ - EmptyState( - action_href="#", - action_label=("portfolios.index.empty.start_button" | translate), - icon="cloud", - message=("portfolios.index.empty.title" | translate), - ) - }} -{% endblock %} diff --git a/templates/portfolios/new.html b/templates/portfolios/new.html deleted file mode 100644 index dbd788f2..00000000 --- a/templates/portfolios/new.html +++ /dev/null @@ -1,54 +0,0 @@ -{% from "components/multi_checkbox_input.html" import MultiCheckboxInput %} -{% from "components/options_input.html" import OptionsInput %} -{% from "components/save_button.html" import SaveButton %} -{% from "components/text_input.html" import TextInput %} - -{% extends "base.html" %} - -{% block content %} - -
- {% include "fragments/flash.html" %} -

New Portfolio Form

- -
- {{ form.csrf_token }} - - {{ TextInput(form.name, optional=False) }} - {{ OptionsInput(form.defense_component, optional=False) }} - {{ TextInput(form.description, paragraph=True) }} - -

{{ "task_orders.new.app_info.project_title" | translate }}

- -
- - {{ OptionsInput(form.app_migration) }} - - {{ OptionsInput(form.native_apps) }} -

{{ "forms.task_order.native_apps.not_sure_help" | translate }}

- {{ MultiCheckboxInput(form.complexity, form.complexity_other) }} - -
- -

{{ "task_orders.new.app_info.team_title" | translate }}

-

{{ "task_orders.new.app_info.subtitle" | translate }}

- {{ MultiCheckboxInput(form.dev_team, form.dev_team_other) }} - {{ OptionsInput(form.team_experience) }} - -
- -
- {{ - SaveButton( - text=('common.save' | translate), - form="portfolio-create", - element="input", - ) - }} -
-
-
-
- -{% endblock %} - diff --git a/templates/portfolios/new/step_1.html b/templates/portfolios/new/step_1.html new file mode 100644 index 00000000..4613b063 --- /dev/null +++ b/templates/portfolios/new/step_1.html @@ -0,0 +1,52 @@ +{% from "components/multi_checkbox_input.html" import MultiCheckboxInput %} +{% from "components/options_input.html" import OptionsInput %} +{% from "components/save_button.html" import SaveButton %} +{% from "components/text_input.html" import TextInput %} +{% from "components/sticky_cta.html" import StickyCTA %} + +{% extends "base.html" %} + +{% block content %} + +
+ {% include "fragments/flash.html" %} +
+

{{ "portfolios.header" | translate }}

+

{{ "New Portfolio" }}

+
+ {{ StickyCTA(text="Create New Portfolio") }} + +
+ {{ form.csrf_token }} +
+
+ {{ TextInput(form.name, optional=False) }} + {{"forms.portfolio.name.help_text" | translate | safe }} +
+
+
+
+ {{ TextInput(form.description, paragraph=True) }} + {{"forms.portfolio.description.help_text" | translate | safe }} +
+
+
+
+ {{ MultiCheckboxInput(form.defense_component, optional=False) }} + {{ "forms.portfolio.defense_component.help_text" | translate | safe }} +
+
+
+ {{ + SaveButton( + text=('common.save' | translate), + form="portfolio-create", + element="input", + ) + }} +
+
+
+
+{% endblock %} + diff --git a/templates/portfolios/reports/application_and_env_spending.html b/templates/portfolios/reports/application_and_env_spending.html index d969a767..f881ed74 100644 --- a/templates/portfolios/reports/application_and_env_spending.html +++ b/templates/portfolios/reports/application_and_env_spending.html @@ -3,9 +3,6 @@

Funds Expended per Application and Environment

- {% set current_month_index = current_month.strftime('%m/%Y') %} - {% set prev_month_index = prev_month.strftime('%m/%Y') %} - {% if not portfolio.applications %} {% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %} @@ -15,20 +12,16 @@ %} {{ EmptyState( - ('portfolios.reports.empty_state.message' | translate), - action_label= ('portfolios.reports.empty_state.action_label' | translate) if can_create_applications else None, - action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None, - icon='chart', - sub_message=message, - add_perms=can_create_applications + header='portfolios.reports.empty_state.message' | translate, + message=message, + button_text="portfolios.applications.empty_state.button_text"|translate, + button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id), + view_only_text="portfolios.applications.empty_state.view_only_text"|translate, + user_can_create=can_create_applications, ) }} + {% else %} - +
@@ -40,37 +33,41 @@ -