Merge pull request #1243 from dod-ccpo/staging

Update master from staging
This commit is contained in:
dandds 2019-12-16 10:54:46 -05:00 committed by GitHub
commit cc3863d926
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
157 changed files with 12248 additions and 3809 deletions

View File

@ -30,7 +30,7 @@ commands:
default: atat_test default: atat_test
container_env: container_env:
type: string type: string
default: -e PGHOST=postgres -e REDIS_URI=redis://redis:6379 default: -e PGHOST=postgres -e REDIS_HOST=redis:6379
steps: steps:
- run: - run:
name: Set up temporary docker network name: Set up temporary docker network
@ -172,7 +172,7 @@ jobs:
command: | command: |
docker run \ docker run \
-e PGHOST=postgres \ -e PGHOST=postgres \
-e REDIS_URI=redis://redis:6379 \ -e REDIS_HOST=redis:6379 \
--network atat \ --network atat \
atat:builder \ atat:builder \
/bin/sh -c "pipenv install --dev && /bin/sh script/cibuild" /bin/sh -c "pipenv install --dev && /bin/sh script/cibuild"
@ -195,7 +195,7 @@ jobs:
docker run -d \ docker run -d \
-e DISABLE_CRL_CHECK=true \ -e DISABLE_CRL_CHECK=true \
-e PGHOST=postgres \ -e PGHOST=postgres \
-e REDIS_URI=redis://redis:6379 \ -e REDIS_HOST=redis:6379 \
-p 8000:8000 \ -p 8000:8000 \
--network atat \ --network atat \
--name test-atat \ --name test-atat \
@ -253,7 +253,7 @@ jobs:
command: | command: |
docker run \ docker run \
-e PGHOST=postgres \ -e PGHOST=postgres \
-e REDIS_URI=redis://redis:6379 \ -e REDIS_HOST=redis:6379 \
--network atat \ --network atat \
atat:builder \ atat:builder \
/bin/sh -c "pipenv install --dev && /bin/sh script/sync-crls && pipenv run pytest --no-cov tests/check_crl_parse.py" /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: - integration-tests:
requires: requires:
- docker-build - docker-build
filters:
branches:
only:
- staging
- master
- deploy-staging: - deploy-staging:
requires: requires:
- test - test

1
.gitignore vendored
View File

@ -33,6 +33,7 @@ static/buildinfo.*
log/* log/*
config/dev.ini config/dev.ini
.env*
# CRLs # CRLs
/crl /crl

View File

@ -3,7 +3,7 @@
"files": "^.secrets.baseline$|^.*pgsslrootcert.yml$", "files": "^.secrets.baseline$|^.*pgsslrootcert.yml$",
"lines": null "lines": null
}, },
"generated_at": "2019-11-26T21:33:43Z", "generated_at": "2019-12-06T21:22:07Z",
"plugins_used": [ "plugins_used": [
{ {
"base64_limit": 4.5, "base64_limit": 4.5,
@ -98,7 +98,7 @@
"hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3",
"is_secret": false, "is_secret": false,
"is_verified": false, "is_verified": false,
"line_number": 21, "line_number": 29,
"type": "Secret Keyword" "type": "Secret Keyword"
} }
], ],
@ -161,7 +161,7 @@
"hashed_secret": "e4f14805dfd1e6af030359090c535e149e6b4207", "hashed_secret": "e4f14805dfd1e6af030359090c535e149e6b4207",
"is_secret": false, "is_secret": false,
"is_verified": false, "is_verified": false,
"line_number": 32, "line_number": 41,
"type": "Hex High Entropy String" "type": "Hex High Entropy String"
} }
], ],

View File

@ -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/seed_roles.py ./script/seed_roles.py
COPY --from=builder /install/script/sync-crls ./script/sync-crls COPY --from=builder /install/script/sync-crls ./script/sync-crls
COPY --from=builder /install/static/ ./static/ COPY --from=builder /install/static/ ./static/
COPY --from=builder /install/fixtures/ ./fixtures
COPY --from=builder /install/uwsgi.ini . COPY --from=builder /install/uwsgi.ini .
COPY --from=builder /usr/local/bin/uwsgi /usr/local/bin/uwsgi COPY --from=builder /usr/local/bin/uwsgi /usr/local/bin/uwsgi

View File

@ -29,6 +29,7 @@ azure-mgmt-subscription = "*"
azure-graphrbac = "*" azure-graphrbac = "*"
msrestazure = "*" msrestazure = "*"
azure-mgmt-authorization = "*" azure-mgmt-authorization = "*"
azure-mgmt-managementgroups = "*"
[dev-packages] [dev-packages]
bandit = "*" bandit = "*"

177
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "6d2ab855267daac877ae7464de9dba5b62b7d89288992f87d8fc6ff0c0d2520f" "sha256": "c2b19c436646705ea3bf4df8c35c2833083f048da37fc619e66f7236153607c5"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -39,11 +39,11 @@
}, },
"apache-libcloud": { "apache-libcloud": {
"hashes": [ "hashes": [
"sha256:201751f738109f25d58dcdfb5804e17216e0dc8f68b522e9e26ac16e0b9ff2ea", "sha256:9bc5cd5c32151bb7a04a7c7de0be9b4a4b8271e348ac91dd79eaaeeae627115f",
"sha256:40215db1bd489d17dc1abfdb289d7f035313c7297b6a7462c79d8287cbbeae91" "sha256:fcc165f2cc2db9a379c6d3a17b3beb9081bb64ba5c0bf7bbb58da864810092f0"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.6.0" "version": "==2.6.1"
}, },
"azure-common": { "azure-common": {
"hashes": [ "hashes": [
@ -68,6 +68,14 @@
"index": "pypi", "index": "pypi",
"version": "==0.60.0" "version": "==0.60.0"
}, },
"azure-mgmt-managementgroups": {
"hashes": [
"sha256:3d5237947458dc94b4a392141174b1c1258d26611241ee104e9006d1d798f682",
"sha256:8194ee6274df865eccd1ed9d385ea625aeba9b8058b9e4fdf547f5207271a775"
],
"index": "pypi",
"version": "==0.2.0"
},
"azure-mgmt-subscription": { "azure-mgmt-subscription": {
"hashes": [ "hashes": [
"sha256:504b4c42ba859070c3c50637ec07ca36aca600e613fcccaa398db22822fe21f1", "sha256:504b4c42ba859070c3c50637ec07ca36aca600e613fcccaa398db22822fe21f1",
@ -117,10 +125,10 @@
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
"sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
], ],
"version": "==2019.9.11" "version": "==2019.11.28"
}, },
"cffi": { "cffi": {
"hashes": [ "hashes": [
@ -248,10 +256,10 @@
}, },
"importlib-metadata": { "importlib-metadata": {
"hashes": [ "hashes": [
"sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", "sha256:b044f07694ef14a6683b097ba56bd081dbc7cdc7c7fe46011e499dfecc082f21",
"sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" "sha256:e6ac600a142cf2db707b1998382cc7fc3b02befb7273876e01b8ad10b9652742"
], ],
"version": "==0.23" "version": "==1.1.0"
}, },
"isodate": { "isodate": {
"hashes": [ "hashes": [
@ -330,10 +338,10 @@
}, },
"more-itertools": { "more-itertools": {
"hashes": [ "hashes": [
"sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", "sha256:53ff73f186307d9c8ef17a9600309154a6ae27f25579e80af4db8f047ba14bc2",
"sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" "sha256:a0ea684c39bc4315ba7aae406596ef191fd84f873d2d2751f84d64e81a7a2d45"
], ],
"version": "==7.2.0" "version": "==8.0.0"
}, },
"msrest": { "msrest": {
"hashes": [ "hashes": [
@ -422,11 +430,11 @@
}, },
"pyopenssl": { "pyopenssl": {
"hashes": [ "hashes": [
"sha256:aeca66338f6de19d1aa46ed634c3b9ae519a64b458f8468aec688e7e3c20f200", "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504",
"sha256:c727930ad54b10fc157015014b666f2d8b41f70c0d03e83ab67624fd3dd5d1e6" "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"
], ],
"index": "pypi", "index": "pypi",
"version": "==19.0.0" "version": "==19.1.0"
}, },
"python-dateutil": { "python-dateutil": {
"hashes": [ "hashes": [
@ -459,22 +467,20 @@
}, },
"pyyaml": { "pyyaml": {
"hashes": [ "hashes": [
"sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
"sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", "sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803",
"sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", "sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc",
"sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", "sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15",
"sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", "sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075",
"sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", "sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd",
"sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", "sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31",
"sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", "sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f",
"sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", "sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c",
"sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", "sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04",
"sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", "sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"
"sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
"sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
], ],
"index": "pypi", "index": "pypi",
"version": "==5.1.2" "version": "==5.2"
}, },
"redis": { "redis": {
"hashes": [ "hashes": [
@ -650,10 +656,10 @@
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
"sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
], ],
"version": "==2019.9.11" "version": "==2019.11.28"
}, },
"chardet": { "chardet": {
"hashes": [ "hashes": [
@ -785,10 +791,10 @@
}, },
"importlib-metadata": { "importlib-metadata": {
"hashes": [ "hashes": [
"sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", "sha256:b044f07694ef14a6683b097ba56bd081dbc7cdc7c7fe46011e499dfecc082f21",
"sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" "sha256:e6ac600a142cf2db707b1998382cc7fc3b02befb7273876e01b8ad10b9652742"
], ],
"version": "==0.23" "version": "==1.1.0"
}, },
"ipdb": { "ipdb": {
"hashes": [ "hashes": [
@ -799,11 +805,11 @@
}, },
"ipython": { "ipython": {
"hashes": [ "hashes": [
"sha256:dfd303b270b7b5232b3d08bd30ec6fd685d8a58cabd54055e3d69d8f029f7280", "sha256:c66c7e27239855828a764b1e8fc72c24a6f4498a2637572094a78c5551fb9d51",
"sha256:ed7ebe1cba899c1c3ccad6f7f1c2d2369464cc77dba8eebc65e2043e19cda995" "sha256:f186b01b36609e0c5d0de27c7ef8e80c990c70478f8c880863004b3489a9030e"
], ],
"index": "pypi", "index": "pypi",
"version": "==7.9.0" "version": "==7.10.1"
}, },
"ipython-genutils": { "ipython-genutils": {
"hashes": [ "hashes": [
@ -908,30 +914,30 @@
}, },
"more-itertools": { "more-itertools": {
"hashes": [ "hashes": [
"sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", "sha256:53ff73f186307d9c8ef17a9600309154a6ae27f25579e80af4db8f047ba14bc2",
"sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" "sha256:a0ea684c39bc4315ba7aae406596ef191fd84f873d2d2751f84d64e81a7a2d45"
], ],
"version": "==7.2.0" "version": "==8.0.0"
}, },
"mypy": { "mypy": {
"hashes": [ "hashes": [
"sha256:1521c186a3d200c399bd5573c828ea2db1362af7209b2adb1bb8532cea2fb36f", "sha256:02d9bdd3398b636723ecb6c5cfe9773025a9ab7f34612c1cde5c7f2292e2d768",
"sha256:31a046ab040a84a0fc38bc93694876398e62bc9f35eca8ccbf6418b7297f4c00", "sha256:088f758a50af31cf8b42688118077292370c90c89232c783ba7979f39ea16646",
"sha256:3b1a411909c84b2ae9b8283b58b48541654b918e8513c20a400bb946aa9111ae", "sha256:28e9fbc96d13397a7ddb7fad7b14f373f91b5cff538e0772e77c270468df083c",
"sha256:48c8bc99380575deb39f5d3400ebb6a8a1cb5cc669bbba4d3bb30f904e0a0e7d", "sha256:30e123b24931f02c5d99307406658ac8f9cd6746f0d45a3dcac2fe5fbdd60939",
"sha256:540c9caa57a22d0d5d3c69047cc9dd0094d49782603eb03069821b41f9e970e9", "sha256:3294821b5840d51a3cd7a2bb63b40fc3f901f6a3cfb3c6046570749c4c7ef279",
"sha256:672e418425d957e276c291930a3921b4a6413204f53fe7c37cad7bc57b9a3391", "sha256:41696a7d912ce16fdc7c141d87e8db5144d4be664a0c699a2b417d393994b0c2",
"sha256:6ed3b9b3fdc7193ea7aca6f3c20549b377a56f28769783a8f27191903a54170f", "sha256:4f42675fa278f3913340bb8c3371d191319704437758d7c4a8440346c293ecb2",
"sha256:9371290aa2cad5ad133e4cdc43892778efd13293406f7340b9ffe99d5ec7c1d9", "sha256:54d205ccce6ed930a8a2ccf48404896d456e8b87812e491cb907a355b1a9c640",
"sha256:ace6ac1d0f87d4072f05b5468a084a45b4eda970e4d26704f201e06d47ab2990", "sha256:6992133c95a2847d309b4b0c899d7054adc60481df6f6b52bb7dee3d5fd157f7",
"sha256:b428f883d2b3fe1d052c630642cc6afddd07d5cd7873da948644508be3b9d4a7", "sha256:6ecbd0e8e371333027abca0922b0c2c632a5b4739a0c61ffbd0733391e39144c",
"sha256:d5bf0e6ec8ba346a2cf35cb55bf4adfddbc6b6576fcc9e10863daa523e418dbb", "sha256:83fa87f556e60782c0fc3df1b37b7b4a840314ba1ac27f3e1a1e10cb37c89c17",
"sha256:d7574e283f83c08501607586b3167728c58e8442947e027d2d4c7dcd6d82f453", "sha256:c87ac7233c629f305602f563db07f5221950fe34fe30af072ac838fa85395f78",
"sha256:dc889c84241a857c263a2b1cd1121507db7d5b5f5e87e77147097230f374d10b", "sha256:de9ec8dba773b78c49e7bec9a35c9b6fc5235682ad1fc2105752ae7c22f4b931",
"sha256:f4748697b349f373002656bf32fede706a0e713d67bfdcf04edf39b1f61d46eb" "sha256:f385a0accf353ca1bca4bbf473b9d83ed18d923fdb809d3a70a385da23e25b6a"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.740" "version": "==0.750"
}, },
"mypy-extensions": { "mypy-extensions": {
"hashes": [ "hashes": [
@ -961,10 +967,10 @@
}, },
"pbr": { "pbr": {
"hashes": [ "hashes": [
"sha256:2c8e420cd4ed4cec4e7999ee47409e876af575d4c35a45840d59e8b5f3155ab8", "sha256:139d2625547dbfa5fb0b81daebb39601c478c21956dc57e2e07b74450a8c506b",
"sha256:b32c8ccaac7b1a20c0ce00ce317642e6cf231cf038f9875e0280e28af5bf7ac9" "sha256:61aa52a0f18b71c5cc58232d2cf8f8d09cd67fcad60b742a60124cb8d6951488"
], ],
"version": "==5.4.3" "version": "==5.4.4"
}, },
"pexpect": { "pexpect": {
"hashes": [ "hashes": [
@ -983,18 +989,17 @@
}, },
"pluggy": { "pluggy": {
"hashes": [ "hashes": [
"sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34" "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
], ],
"version": "==0.13.0" "version": "==0.13.1"
}, },
"prompt-toolkit": { "prompt-toolkit": {
"hashes": [ "hashes": [
"sha256:46642344ce457641f28fc9d1c9ca939b63dadf8df128b86f1b9860e59c73a5e4", "sha256:0278d2f51b5ceba6ea8da39f76d15684e84c996b325475f6e5720edc584326a7",
"sha256:e7f8af9e3d70f514373bf41aa51bc33af12a6db3f71461ea47fea985defb2c31", "sha256:63daee79aa8366c8f1c637f1a4876b890da5fc92a19ebd2f7080ebacb901e990"
"sha256:f15af68f66e664eaa559d4ac8a928111eebd5feda0c11738b5998045224829db"
], ],
"version": "==2.0.10" "version": "==3.0.2"
}, },
"ptyprocess": { "ptyprocess": {
"hashes": [ "hashes": [
@ -1012,10 +1017,10 @@
}, },
"pygments": { "pygments": {
"hashes": [ "hashes": [
"sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
"sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"
], ],
"version": "==2.4.2" "version": "==2.5.2"
}, },
"pylint": { "pylint": {
"hashes": [ "hashes": [
@ -1058,11 +1063,11 @@
}, },
"pytest-mock": { "pytest-mock": {
"hashes": [ "hashes": [
"sha256:b3514caac35fe3f05555923eabd9546abce11571cc2ddf7d8615959d04f2c89e", "sha256:96a0cebc66e09930be2a15b03333d90b59584d3fb011924f81c14b50ee0afbba",
"sha256:ea502c3891599c26243a3a847ccf0b1d20556678c528f86c98e3cd6d40c5cf11" "sha256:e5381be2608e49547f5e47633c5f81241ebf6206d17ce516a7a18d5a917e3859"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.11.2" "version": "==1.12.1"
}, },
"pytest-watch": { "pytest-watch": {
"hashes": [ "hashes": [
@ -1080,22 +1085,20 @@
}, },
"pyyaml": { "pyyaml": {
"hashes": [ "hashes": [
"sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
"sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", "sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803",
"sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", "sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc",
"sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", "sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15",
"sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", "sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075",
"sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", "sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd",
"sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", "sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31",
"sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", "sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f",
"sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", "sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c",
"sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", "sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04",
"sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", "sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"
"sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
"sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
], ],
"index": "pypi", "index": "pypi",
"version": "==5.1.2" "version": "==5.2"
}, },
"regex": { "regex": {
"hashes": [ "hashes": [

View File

@ -168,12 +168,7 @@ Testing file uploads and downloads locally requires a few configuration options.
In the flask config (`config/base.ini`, perhaps): In the flask config (`config/base.ini`, perhaps):
``` ```
CSP=<aws | azure | mock> CSP=< azure | mock>
AWS_REGION_NAME=""
AWS_ACCESS_KEY=""
AWS_SECRET_KEY=""
AWS_BUCKET_NAME=""
AZURE_STORAGE_KEY="" AZURE_STORAGE_KEY=""
AZURE_ACCOUNT_NAME="" 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`: 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=<aws | azure | mock> CLOUD_PROVIDER=<azure | mock>
AZURE_ACCOUNT_NAME="" AZURE_ACCOUNT_NAME=""
AZURE_CONTAINER_NAME="" AZURE_CONTAINER_NAME=""
``` ```
@ -223,6 +218,9 @@ To generate coverage reports for the Javascript tests:
## Configuration ## Configuration
- `ASSETS_URL`: URL to host which serves static assets (such as a CDN). - `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. - `BLOB_STORAGE_URL`: URL to Azure blob storage container.
- `CAC_URL`: URL for the CAC authentication route. - `CAC_URL`: URL for the CAC authentication route.
- `CA_CHAIN`: Path to the CA chain file. - `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". - `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. - `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. - `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 - `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. - `PGDATABASE`: String specifying the name of the postgres database.
- `PGHOST`: String specifying the hostname 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 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. 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, For further information about Ghost Inspector and its use in AT-AT, check out [its README](./uitests/README.md)
you will need the following: in the `uitests` directory.
- [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=<SUITE_ID> -e GI_API_KEY=<API KEY> -e NGROK_TOKEN=<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
```
## Notes ## Notes

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -200,46 +200,78 @@ def make_config(direct_config=None):
ENV_CONFIG_FILENAME = os.path.join( ENV_CONFIG_FILENAME = os.path.join(
os.path.dirname(__file__), "../config/", "{}.ini".format(ENV.lower()) 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 = ConfigParser(allow_no_value=True)
config.optionxform = str config.optionxform = str
config_files = [BASE_CONFIG_FILENAME, ENV_CONFIG_FILENAME] 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. # ENV_CONFIG will override values in BASE_CONFIG.
config.read(config_files) 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 # Check for ENV variables as a final source of overrides
for confsetting in config.options("default"): apply_config_from_environment(config)
env_override = os.getenv(confsetting.upper())
if env_override:
config.set("default", confsetting, env_override)
# override if a dictionary of options has been given # override if a dictionary of options has been given
if direct_config: if direct_config:
config.read_dict({"default": direct_config}) config.read_dict({"default": direct_config})
# Assemble DATABASE_URI value # Assemble DATABASE_URI value
database_uri = ( database_uri = "postgres://{}:{}@{}:{}/{}".format( # pragma: allowlist secret
"postgres://" config.get("default", "PGUSER"),
+ config.get("default", "PGUSER") config.get("default", "PGPASSWORD"),
+ ":" config.get("default", "PGHOST"),
+ config.get("default", "PGPASSWORD") config.get("default", "PGPORT"),
+ "@" config.get("default", "PGDATABASE"),
+ config.get("default", "PGHOST")
+ ":"
+ config.get("default", "PGPORT")
+ "/"
+ config.get("default", "PGDATABASE")
) )
config.set("default", "DATABASE_URI", database_uri) 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) 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): def make_redis(app, config):
r = redis.Redis.from_url(config["REDIS_URI"]) r = redis.Redis.from_url(config["REDIS_URI"])
app.redis = r app.redis = r

View File

@ -3,6 +3,7 @@ import re
from uuid import uuid4 from uuid import uuid4
from atst.models.user import User from atst.models.user import User
from atst.models.application import Application
from atst.models.environment import Environment from atst.models.environment import Environment
from atst.models.environment_role import EnvironmentRole from atst.models.environment_role import EnvironmentRole
@ -399,13 +400,14 @@ REMOTE_ROOT_ROLE_DEF_ID = "/providers/Microsoft.Authorization/roleDefinitions/00
class AzureSDKProvider(object): class AzureSDKProvider(object):
def __init__(self): def __init__(self):
from azure.mgmt import subscription, authorization from azure.mgmt import subscription, authorization, managementgroups
import azure.graphrbac as graphrbac import azure.graphrbac as graphrbac
import azure.common.credentials as credentials import azure.common.credentials as credentials
from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD
self.subscription = subscription self.subscription = subscription
self.authorization = authorization self.authorization = authorization
self.managementgroups = managementgroups
self.graphrbac = graphrbac self.graphrbac = graphrbac
self.credentials = credentials self.credentials = credentials
# may change to a JEDI cloud # may change to a JEDI cloud
@ -428,42 +430,23 @@ class AzureCloudProvider(CloudProviderInterface):
def create_environment( def create_environment(
self, auth_credentials: Dict, user: User, environment: 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) 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 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? management_group = self._create_management_group(
sku_id = AZURE_SKU_ID credentials, management_group_id, display_name, parent_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=<AdPrincipal: for AT-AT user>
) )
# These 2 seem like something that might be worthwhile to allow tiebacks to return management_group
# 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
def create_atat_admin_user( def create_atat_admin_user(
self, auth_credentials: Dict, csp_environment_id: str self, auth_credentials: Dict, csp_environment_id: str
@ -502,6 +485,82 @@ class AzureCloudProvider(CloudProviderInterface):
"role_name": role_assignment_id, "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): def _get_management_service_principal(self):
# we really should be using graph.microsoft.com, but i'm getting # we really should be using graph.microsoft.com, but i'm getting
# "expired token" errors for that # "expired token" errors for that

View File

@ -1,327 +1,121 @@
from itertools import groupby from collections import defaultdict
import pendulum import json
from decimal import Decimal from decimal import Decimal
from collections import OrderedDict
class ReportingInterface: def load_fixture_data():
def monthly_totals_for_environment(environment): with open("fixtures/fixture_spend_data.json") as json_file:
"""Return the monthly totals for the specified environment. 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() returns an array of application and environment spending for the
portfolio. Applications and their nested environments are sorted in
alphabetical order by name.
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:
{ {
"environments": { "X-Wing": { "Prod": { "01/2018": 75.42 } } }, name
"applications": { "X-Wing": { "01/2018": 75.42 } }, this_month
"portfolio": { "01/2018": 75.42 }, last_month
total
environments [
{
name
this_month
last_month
total
}
]
} }
]
""" """
applications = portfolio.applications if portfolio.name in cls.FIXTURE_SPEND_DATA:
if portfolio.name in self.REPORT_FIXTURE_MAP: applications = cls.FIXTURE_SPEND_DATA[portfolio.name]["applications"]
applications = self.REPORT_FIXTURE_MAP[portfolio.name]["applications"] return sorted(
environments = { [
application.name: { cls._get_application_monthly_totals(application)
env.name: self.monthly_totals_for_environment(env.id) for application in applications
for env in application.environments ],
} key=lambda app: app["name"],
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])
) )
return {} return []
def get_expired_task_orders(self, portfolio): @classmethod
return [ 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, name
"number": task_order.number, this_month
"period_of_performance": { last_month
"start_date": task_order.start_date, total
"end_date": task_order.end_date, environments: [
}, {
"total_obligated_funds": task_order.total_obligated_funds, name
"expended_funds": ( this_month
task_order.total_obligated_funds last_month
* Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS) 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 {}

View File

@ -3,9 +3,10 @@ from flask import current_app as app
from atst.database import db from atst.database import db
from atst.models import ( from atst.models import (
EnvironmentRole,
ApplicationRole,
Environment, Environment,
EnvironmentRole,
Application,
ApplicationRole,
ApplicationRoleStatus, ApplicationRoleStatus,
) )
from atst.domain.exceptions import NotFoundError from atst.domain.exceptions import NotFoundError
@ -105,8 +106,9 @@ class EnvironmentRoles(object):
def disable(cls, environment_role_id): def disable(cls, environment_role_id):
environment_role = EnvironmentRoles.get_by_id(environment_role_id) environment_role = EnvironmentRoles.get_by_id(environment_role_id)
credentials = environment_role.environment.csp_credentials if environment_role.csp_user_id and not environment_role.environment.is_pending:
app.csp.cloud.disable_user(credentials, environment_role.csp_user_id) credentials = environment_role.environment.csp_credentials
app.csp.cloud.disable_user(credentials, environment_role.csp_user_id)
environment_role.status = EnvironmentRole.Status.DISABLED environment_role.status = EnvironmentRole.Status.DISABLED
db.session.add(environment_role) db.session.add(environment_role)
@ -125,3 +127,15 @@ class EnvironmentRoles(object):
.one_or_none() .one_or_none()
) )
return existing_env_role 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()
)

View File

@ -1,15 +1,44 @@
from flask import current_app from flask import current_app
from itertools import groupby
class Reports: class Reports:
@classmethod @classmethod
def monthly_totals(cls, portfolio): def monthly_spending(cls, portfolio):
return current_app.csp.reports.monthly_totals(portfolio) return current_app.csp.reports.get_portfolio_monthly_spending(portfolio)
@classmethod @classmethod
def expired_task_orders(cls, portfolio): 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 @classmethod
def obligated_funds_by_JEDI_clin(cls, portfolio): 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

View File

@ -1,9 +1,11 @@
import datetime import datetime
from sqlalchemy.exc import IntegrityError
from atst.database import db from atst.database import db
from atst.models.clin import CLIN from atst.models.clin import CLIN
from atst.models.task_order import TaskOrder, SORT_ORDERING from atst.models.task_order import TaskOrder, SORT_ORDERING
from . import BaseDomainClass from . import BaseDomainClass
from .exceptions import AlreadyExistsError
class TaskOrders(BaseDomainClass): class TaskOrders(BaseDomainClass):
@ -11,12 +13,15 @@ class TaskOrders(BaseDomainClass):
resource_name = "task_order" resource_name = "task_order"
@classmethod @classmethod
def create(cls, creator, portfolio_id, number, clins, pdf): def create(cls, portfolio_id, number, clins, pdf):
task_order = TaskOrder( task_order = TaskOrder(portfolio_id=portfolio_id, number=number, pdf=pdf)
portfolio_id=portfolio_id, creator=creator, number=number, pdf=pdf
)
db.session.add(task_order) 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) TaskOrders.create_clins(task_order.id, clins)
@ -37,7 +42,12 @@ class TaskOrders(BaseDomainClass):
task_order.number = number task_order.number = number
db.session.add(task_order) db.session.add(task_order)
db.session.commit() try:
db.session.commit()
except IntegrityError:
db.session.rollback()
raise AlreadyExistsError("task_order")
return task_order return task_order
@classmethod @classmethod
@ -66,10 +76,12 @@ class TaskOrders(BaseDomainClass):
db.session.commit() db.session.commit()
@classmethod @classmethod
def sort(cls, task_orders: [TaskOrder]) -> [TaskOrder]: def sort_by_status(cls, task_orders):
# Sorts a list of task orders on two keys: status (primary) and time_created (secondary) by_status = {status.value: [] for status in SORT_ORDERING}
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)) for task_order in task_orders:
by_status[task_order.display_status].append(task_order)
return by_status return by_status
@classmethod @classmethod

View File

@ -5,6 +5,7 @@ from flask import render_template
from jinja2 import contextfilter from jinja2 import contextfilter
from jinja2.exceptions import TemplateNotFound from jinja2.exceptions import TemplateNotFound
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
from decimal import DivisionByZero as DivisionByZeroException
def iconSvg(name): def iconSvg(name):
@ -38,6 +39,14 @@ def usPhone(number):
return "+1 ({}) {} - {}".format(phone[0:3], phone[3:6], phone[6:]) 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"): def formattedDate(value, formatter="%m/%d/%Y"):
if value: if value:
return value.strftime(formatter) return value.strftime(formatter)
@ -76,6 +85,7 @@ def register_filters(app):
app.jinja_env.filters["pageWindow"] = pageWindow app.jinja_env.filters["pageWindow"] = pageWindow
app.jinja_env.filters["renderAuditEvent"] = renderAuditEvent app.jinja_env.filters["renderAuditEvent"] = renderAuditEvent
app.jinja_env.filters["withExtraParams"] = with_extra_params app.jinja_env.filters["withExtraParams"] = with_extra_params
app.jinja_env.filters["obligatedFundingGraphWidth"] = obligatedFundingGraphWidth
@contextfilter @contextfilter
def translateWithoutCache(context, *kwargs): def translateWithoutCache(context, *kwargs):

View File

@ -3,112 +3,14 @@ from atst.utils.localization import translate
SERVICE_BRANCHES = [ SERVICE_BRANCHES = [
("", "- Select -"), ("air_force", translate("forms.portfolio.defense_component.choices.air_force")),
("Air Force, Department of the", "Air Force, Department of the"), ("army", translate("forms.portfolio.defense_component.choices.army")),
("Army and Air Force Exchange Service", "Army and Air Force Exchange Service"),
("Army, Department of the", "Army, Department of the"),
( (
"Defense Advanced Research Applications Agency", "marine_corps",
"Defense Advanced Research Applications Agency", translate("forms.portfolio.defense_component.choices.marine_corps"),
), ),
("Defense Commissary Agency", "Defense Commissary Agency"), ("navy", translate("forms.portfolio.defense_component.choices.navy")),
("Defense Contract Audit Agency", "Defense Contract Audit Agency"), ("other", translate("forms.portfolio.defense_component.choices.other")),
("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")),
] ]
ENV_ROLE_NO_ACCESS = "No Access" ENV_ROLE_NO_ACCESS = "No Access"

View File

@ -1,33 +1,25 @@
from wtforms.fields import ( from wtforms.fields import (
RadioField,
SelectField,
SelectMultipleField, SelectMultipleField,
StringField, StringField,
TextAreaField, TextAreaField,
) )
from wtforms.validators import Length, Optional from wtforms.validators import Length, InputRequired
from wtforms.widgets import ListWidget, CheckboxInput from wtforms.widgets import ListWidget, CheckboxInput
from .forms import BaseForm, remove_empty_string from .forms import BaseForm
from atst.utils.localization import translate from atst.utils.localization import translate
from .data import ( from .data import SERVICE_BRANCHES
APPLICATION_COMPLEXITY,
APP_MIGRATION,
DEV_TEAM,
SERVICE_BRANCHES,
TEAM_EXPERIENCE,
)
class PortfolioForm(BaseForm): class PortfolioForm(BaseForm):
name = StringField( name = StringField(
translate("forms.portfolio.name_label"), translate("forms.portfolio.name.label"),
validators=[ validators=[
Length( Length(
min=4, min=4,
max=100, 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): class PortfolioCreationForm(BaseForm):
name = StringField( name = StringField(
translate("forms.portfolio.name_label"), translate("forms.portfolio.name.label"),
validators=[ validators=[
Length( Length(
min=4, min=4,
max=100, max=100,
message=translate("forms.portfolio.name_length_validation_message"), message=translate("forms.portfolio.name.length_validation_message"),
) )
], ],
) )
description = TextAreaField(translate("forms.portfolio.description.label"),)
defense_component = SelectField( defense_component = SelectMultipleField(
translate("forms.task_order.defense_component_label"),
choices=SERVICE_BRANCHES, 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), widget=ListWidget(prefix_label=False),
option_widget=CheckboxInput(), option_widget=CheckboxInput(),
) validators=[
InputRequired(
complexity_other = StringField( message=translate(
translate("forms.task_order.complexity_other_label"), "forms.portfolio.defense_component.validation_message"
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()],
) )

View File

@ -7,13 +7,13 @@ from wtforms.fields import (
HiddenField, HiddenField,
) )
from wtforms.fields.html5 import DateField 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 flask_wtf import FlaskForm
from numbers import Number from numbers import Number
from .data import JEDI_CLIN_TYPES from .data import JEDI_CLIN_TYPES
from .fields import SelectField from .fields import SelectField
from .forms import BaseForm from .forms import BaseForm, remove_empty_string
from atst.utils.localization import translate from atst.utils.localization import translate
from flask import current_app as app from flask import current_app as app
@ -61,9 +61,7 @@ class CLINForm(FlaskForm):
coerce=coerce_enum, coerce=coerce_enum,
) )
number = StringField( number = StringField(label=translate("task_orders.form.clin_number_label"))
label=translate("task_orders.form.clin_number_label"), validators=[Optional()]
)
start_date = DateField( start_date = DateField(
translate("task_orders.form.pop_start"), translate("task_orders.form.pop_start"),
description=translate("task_orders.form.pop_example"), description=translate("task_orders.form.pop_example"),
@ -136,7 +134,10 @@ class AttachmentForm(BaseForm):
class TaskOrderForm(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( pdf = FormField(
AttachmentForm, AttachmentForm,
label=translate("task_orders.form.supporting_docs_size_limit"), label=translate("task_orders.form.supporting_docs_size_limit"),

View File

@ -12,7 +12,10 @@ class ApplicationInvitation(
__tablename__ = "application_invitations" __tablename__ = "application_invitations"
application_role_id = Column( 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( role = relationship(
"ApplicationRole", "ApplicationRole",

View File

@ -46,7 +46,9 @@ class ApplicationRole(
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=True 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( permission_sets = relationship(
"PermissionSet", secondary=application_roles_permission_sets "PermissionSet", secondary=application_roles_permission_sets

View File

@ -23,12 +23,12 @@ class CLIN(Base, mixins.TimestampsMixin):
task_order_id = Column(ForeignKey("task_orders.id"), nullable=False) task_order_id = Column(ForeignKey("task_orders.id"), nullable=False)
task_order = relationship("TaskOrder") task_order = relationship("TaskOrder")
number = Column(String, nullable=True) number = Column(String, nullable=False)
start_date = Column(Date, nullable=True) start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=True) end_date = Column(Date, nullable=False)
total_amount = Column(Numeric(scale=2), nullable=True) total_amount = Column(Numeric(scale=2), nullable=False)
obligated_amount = Column(Numeric(scale=2), nullable=True) obligated_amount = Column(Numeric(scale=2), nullable=False)
jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=True) jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=False)
# #
# NOTE: For now obligated CLINS are CLIN 1 + CLIN 3 # NOTE: For now obligated CLINS are CLIN 1 + CLIN 3
@ -65,4 +65,6 @@ class CLIN(Base, mixins.TimestampsMixin):
@property @property
def is_active(self): 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

View File

@ -43,7 +43,9 @@ class EnvironmentRole(
COMPLETED = "completed" COMPLETED = "completed"
DISABLED = "disabled" 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): def __repr__(self):
return "<EnvironmentRole(role='{}', user='{}', environment='{}', id='{}')>".format( return "<EnvironmentRole(role='{}', user='{}', environment='{}', id='{}')>".format(

View File

@ -31,23 +31,29 @@ class InvitesMixin(object):
@declared_attr @declared_attr
def inviter_id(cls): 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 @declared_attr
def inviter(cls): def inviter(cls):
return relationship("User", foreign_keys=[cls.inviter_id]) 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) email = Column(String, nullable=False)
dod_id = Column(String) dod_id = Column(String, nullable=False)
first_name = Column(String) first_name = Column(String, nullable=False)
last_name = Column(String) last_name = Column(String, nullable=False)
phone_number = Column(String) phone_number = Column(String)
phone_ext = Column(String) phone_ext = Column(String)

View File

@ -1,6 +1,5 @@
from sqlalchemy import Column, String from sqlalchemy import Column, String
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.types import ARRAY
from itertools import chain from itertools import chain
from atst.models.base import Base from atst.models.base import Base
@ -18,17 +17,11 @@ class Portfolio(
__tablename__ = "portfolios" __tablename__ = "portfolios"
id = types.Id() id = types.Id()
name = Column(String) name = Column(String, nullable=False)
defense_component = Column(String) # Department of Defense Component
app_migration = Column(String) # App Migration
complexity = Column(ARRAY(String)) # Application Complexity
complexity_other = Column(String)
description = Column(String) description = Column(String)
dev_team = Column(ARRAY(String)) # Development Team defense_component = Column(
dev_team_other = Column(String) String, nullable=False
native_apps = Column(String) # Native Apps ) # Department of Defense Component
team_experience = Column(String) # Team Experience
applications = relationship( applications = relationship(
"Application", "Application",

View File

@ -12,7 +12,7 @@ class PortfolioInvitation(
__tablename__ = "portfolio_invitations" __tablename__ = "portfolio_invitations"
portfolio_role_id = Column( 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( role = relationship(
"PortfolioRole", "PortfolioRole",

View File

@ -52,7 +52,9 @@ class PortfolioRole(
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=True 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( permission_sets = relationship(
"PermissionSet", secondary=portfolio_roles_permission_sets "PermissionSet", secondary=portfolio_roles_permission_sets

View File

@ -1,5 +1,5 @@
from datetime import timedelta
from enum import Enum from enum import Enum
from decimal import Decimal
from sqlalchemy import Column, DateTime, ForeignKey, String from sqlalchemy import Column, DateTime, ForeignKey, String
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
@ -17,15 +17,16 @@ class Status(Enum):
ACTIVE = "Active" ACTIVE = "Active"
UPCOMING = "Upcoming" UPCOMING = "Upcoming"
EXPIRED = "Expired" EXPIRED = "Expired"
UNSIGNED = "Not signed" UNSIGNED = "Unsigned"
SORT_ORDERING = { SORT_ORDERING = [
status: order Status.ACTIVE,
for (order, status) in enumerate( Status.DRAFT,
[Status.DRAFT, Status.ACTIVE, Status.UPCOMING, Status.EXPIRED, Status.UNSIGNED] Status.UPCOMING,
) Status.EXPIRED,
} Status.UNSIGNED,
]
class TaskOrder(Base, mixins.TimestampsMixin): class TaskOrder(Base, mixins.TimestampsMixin):
@ -33,15 +34,12 @@ class TaskOrder(Base, mixins.TimestampsMixin):
id = types.Id() id = types.Id()
portfolio_id = Column(ForeignKey("portfolios.id")) portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False)
portfolio = relationship("Portfolio") 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_attachment_id = Column(ForeignKey("attachments.id"))
_pdf = relationship("Attachment", foreign_keys=[pdf_attachment_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) signer_dod_id = Column(String)
signed_at = Column(DateTime) signed_at = Column(DateTime)
@ -134,12 +132,11 @@ class TaskOrder(Base, mixins.TimestampsMixin):
@property @property
def start_date(self): 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 @property
def end_date(self): def end_date(self):
default_end_date = self.start_date + timedelta(days=1) return max((c.end_date for c in self.clins), default=None)
return max((c.end_date for c in self.clins), default=default_end_date)
@property @property
def days_to_expiration(self): def days_to_expiration(self):
@ -173,6 +170,11 @@ class TaskOrder(Base, mixins.TimestampsMixin):
# Faked for display purposes # Faked for display purposes
return 50 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 @property
def display_status(self): def display_status(self):
return self.status.value return self.status.value

View File

@ -56,8 +56,8 @@ class User(
email = Column(String) email = Column(String)
dod_id = Column(String, unique=True, nullable=False) dod_id = Column(String, unique=True, nullable=False)
first_name = Column(String) first_name = Column(String, nullable=False)
last_name = Column(String) last_name = Column(String, nullable=False)
phone_number = Column(String) phone_number = Column(String)
phone_ext = Column(String) phone_ext = Column(String)
service_branch = Column(String) service_branch = Column(String)

View File

@ -1,7 +1,8 @@
from flask import render_template from flask import render_template, g
from .blueprint import applications_bp from .blueprint import applications_bp
from atst.domain.authz.decorator import user_can_access_decorator as user_can 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 from atst.models.permissions import Permissions
@ -23,4 +24,11 @@ def has_portfolio_applications(_user, portfolio=None, **_kwargs):
message="view portfolio applications", message="view portfolio applications",
) )
def portfolio_applications(portfolio_id): 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
)

View File

@ -20,6 +20,7 @@ from atst.domain.permission_sets import PermissionSets
from atst.utils.flash import formatted_flash as flash from atst.utils.flash import formatted_flash as flash
from atst.utils.localization import translate from atst.utils.localization import translate
from atst.jobs import send_mail from atst.jobs import send_mail
from atst.routes.errors import log_error
def get_environments_obj_for_app(application): 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) flash("application_member_updated", user_name=app_role.user_name)
except GeneralCSPException: except GeneralCSPException as exc:
log_error(exc)
flash( flash(
"application_member_update_error", user_name=app_role.user_name, "application_member_update_error", user_name=app_role.user_name,
) )

View File

@ -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 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 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") @portfolios_bp.route("/portfolios/new")
def new_portfolio(): def new_portfolio_step_1():
form = PortfolioCreationForm() form = PortfolioCreationForm()
return render_template("portfolios/new/step_1.html", form=form)
return render_template("portfolios/new.html", form=form)
@portfolios_bp.route("/portfolios", methods=["POST"]) @portfolios_bp.route("/portfolios", methods=["POST"])
@ -38,16 +27,19 @@ def create_portfolio():
url_for("applications.portfolio_applications", portfolio_id=portfolio.id) url_for("applications.portfolio_applications", portfolio_id=portfolio.id)
) )
else: 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/<portfolio_id>/reports") @portfolios_bp.route("/portfolios/<portfolio_id>/reports")
@user_can(Permissions.VIEW_PORTFOLIO_REPORTS, message="view portfolio reports") @user_can(Permissions.VIEW_PORTFOLIO_REPORTS, message="view portfolio reports")
def reports(portfolio_id): def reports(portfolio_id):
portfolio = Portfolios.get(g.current_user, portfolio_id) portfolio = Portfolios.get(g.current_user, portfolio_id)
today = date.today()
current_month = date(int(today.year), int(today.month), 15) current_obligated_funds = Reports.obligated_funds_by_JEDI_clin(portfolio)
prev_month = current_month - timedelta(days=28)
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 # wrapped in str() because the sum of obligated funds returns a Decimal object
total_portfolio_value = str( total_portfolio_value = str(
sum( sum(
@ -59,12 +51,10 @@ def reports(portfolio_id):
"portfolios/reports/index.html", "portfolios/reports/index.html",
portfolio=portfolio, portfolio=portfolio,
total_portfolio_value=total_portfolio_value, 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), expired_task_orders=Reports.expired_task_orders(portfolio),
monthly_totals=Reports.monthly_totals(portfolio), monthly_spending=Reports.monthly_spending(portfolio),
current_month=current_month, retrieved=datetime.now(), # mocked datetime of reporting data retrival
prev_month=prev_month,
now=datetime.now(), # mocked datetime of reporting data retrival
) )

View File

@ -6,7 +6,6 @@ from atst.domain.portfolios import Portfolios
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.forms.task_order import SignatureForm from atst.forms.task_order import SignatureForm
from atst.models import Permissions from atst.models import Permissions
from atst.models.task_order import Status as TaskOrderStatus
@task_orders_bp.route("/task_orders/<task_order_id>/review") @task_orders_bp.route("/task_orders/<task_order_id>/review")
@ -28,14 +27,9 @@ def review_task_order(task_order_id):
@user_can(Permissions.VIEW_PORTFOLIO_FUNDING, message="view portfolio funding") @user_can(Permissions.VIEW_PORTFOLIO_FUNDING, message="view portfolio funding")
def portfolio_funding(portfolio_id): def portfolio_funding(portfolio_id):
portfolio = Portfolios.get(g.current_user, portfolio_id) portfolio = Portfolios.get(g.current_user, portfolio_id)
task_orders = TaskOrders.sort(portfolio.task_orders) task_orders = TaskOrders.sort_by_status(portfolio.task_orders)
label_colors = { to_count = len(portfolio.task_orders)
TaskOrderStatus.DRAFT: "warning", # TODO: Get expended amount from the CSP
TaskOrderStatus.ACTIVE: "success",
TaskOrderStatus.UPCOMING: "info",
TaskOrderStatus.EXPIRED: "error",
TaskOrderStatus.UNSIGNED: "purple",
}
return render_template( 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
) )

View File

@ -10,7 +10,7 @@ from flask import (
from .blueprint import task_orders_bp from .blueprint import task_orders_bp
from atst.domain.authz.decorator import user_can_access_decorator as user_can 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.domain.task_orders import TaskOrders
from atst.forms.task_order import TaskOrderForm, SignatureForm from atst.forms.task_order import TaskOrderForm, SignatureForm
from atst.models.permissions import Permissions from atst.models.permissions import Permissions
@ -50,7 +50,26 @@ def render_task_orders_edit(
return render_template(template, **render_args) 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_data, next_page, current_template, portfolio_id=None, task_order_id=None
): ):
form = None form = None
@ -60,14 +79,8 @@ def update_task_order(
else: else:
form = TaskOrderForm(form_data) form = TaskOrderForm(form_data)
if form.validate(): task_order = update_task_order(form, portfolio_id, task_order_id)
task_order = None if task_order:
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)
return redirect(url_for(next_page, task_order_id=task_order.id)) return redirect(url_for(next_page, task_order_id=task_order.id))
else: else:
return ( 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" next_page = "task_orders.form_step_two_add_number"
current_template = "task_orders/step_1.html" current_template = "task_orders/step_1.html"
return update_task_order( return update_and_render_next(
form_data, form_data,
next_page, next_page,
current_template, current_template,
@ -176,14 +189,8 @@ def cancel_edit(task_order_id=None, portfolio_id=None):
else: else:
form = TaskOrderForm(form_data) form = TaskOrderForm(form_data)
if form.validate(flash_invalid=False): update_task_order(form, portfolio_id, task_order_id, 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
)
elif not save and task_order_id: elif not save and task_order_id:
TaskOrders.delete(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" next_page = "task_orders.form_step_three_add_clins"
current_template = "task_orders/step_2.html" 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 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" next_page = "task_orders.form_step_four_review"
current_template = "task_orders/step_3.html" 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 form_data, next_page, current_template, task_order_id=task_order_id
) )

View File

@ -96,6 +96,11 @@ MESSAGES = {
"message_template": "<p>Please see below.</p>", "message_template": "<p>Please see below.</p>",
"category": "error", "category": "error",
}, },
"insufficient_funds": {
"title_template": "Insufficient Funds",
"message_template": "",
"category": "warning",
},
"logged_out": { "logged_out": {
"title_template": translate("flash.logged_out"), "title_template": translate("flash.logged_out"),
"message_template": """ "message_template": """
@ -160,6 +165,11 @@ MESSAGES = {
"message_template": translate("task_orders.form.draft_alert_message"), "message_template": translate("task_orders.form.draft_alert_message"),
"category": "warning", "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": { "task_order_submitted": {
"title_template": "Your Task Order has been uploaded successfully.", "title_template": "Your Task Order has been uploaded successfully.",
"message_template": """ "message_template": """

View File

@ -1,5 +1,8 @@
[default] [default]
ASSETS_URL ASSETS_URL
AZURE_ACCOUNT_NAME
AZURE_STORAGE_KEY
AZURE_TO_BUCKET_NAME
BLOB_STORAGE_URL=http://localhost:8000/ BLOB_STORAGE_URL=http://localhost:8000/
CAC_URL = http://localhost:8000/login-redirect CAC_URL = http://localhost:8000/login-redirect
CA_CHAIN = ssl/server-certs/ca-chain.pem CA_CHAIN = ssl/server-certs/ca-chain.pem
@ -15,6 +18,11 @@ DISABLE_CRL_CHECK = false
ENVIRONMENT = dev ENVIRONMENT = dev
LIMIT_CONCURRENT_SESSIONS = false LIMIT_CONCURRENT_SESSIONS = false
LOG_JSON = false LOG_JSON = false
MAIL_PASSWORD
MAIL_PORT
MAIL_SENDER
MAIL_SERVER
MAIL_TLS
PERMANENT_SESSION_LIFETIME = 1800 PERMANENT_SESSION_LIFETIME = 1800
PGDATABASE = atat PGDATABASE = atat
PGHOST = localhost PGHOST = localhost
@ -24,7 +32,10 @@ PGSSLMODE = prefer
PGSSLROOTCERT PGSSLROOTCERT
PGUSER = postgres PGUSER = postgres
PORT=8000 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 SECRET_KEY = change_me_into_something_secret
SERVER_NAME SERVER_NAME
SESSION_COOKIE_NAME=atat SESSION_COOKIE_NAME=atat

View File

@ -1,8 +1,6 @@
[default] [default]
DEBUG = true
PGHOST = postgreshost
PGDATABASE = atat_test
REDIS_URI = redis://redishost:6379
CRL_STORAGE_CONTAINER = tests/fixtures/crl CRL_STORAGE_CONTAINER = tests/fixtures/crl
WTF_CSRF_ENABLED = false
CSP=mock-test CSP=mock-test
DEBUG = true
PGDATABASE = atat_test
WTF_CSRF_ENABLED = false

View File

@ -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. - 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_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. - 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. 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 ## 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 ### 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: 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]" 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 <VAULT NAME> --name <NAME OF PARAM> --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 # Setting Up FlexVol for Secrets
## Preparing Azure Environment ## 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 ## 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. 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: "<NAME OF KEY VAULT>"
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
```

View File

@ -6,15 +6,30 @@ metadata:
namespace: atat namespace: atat
data: data:
ASSETS_URL: https://atat-cdn.azureedge.net/ 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/ 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 CDN_ORIGIN: https://azure.atat.code.mil
CELERY_DEFAULT_QUEUE: celery-master
CSP: azure CSP: azure
DEBUG: "0"
FLASK_ENV: master FLASK_ENV: master
LOG_JSON: "true" 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 PGSSLMODE: verify-full
PGSSLROOTCERT: /opt/atat/atst/ssl/pgsslrootcert.crt 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/ STATIC_URL: https://atat-cdn.azureedge.net/static/
TZ: UTC TZ: UTC
UWSGI_CONFIG_FULLPATH: /opt/atat/atst/uwsgi.ini UWSGI_CONFIG_FULLPATH: /opt/atat/atst/uwsgi.ini

View File

@ -5,8 +5,10 @@ metadata:
name: atst-nginx name: atst-nginx
namespace: atat namespace: atat
data: data:
nginx-config: |- atst.conf: |-
server { server {
access_log /var/log/nginx/access.log json;
listen ${PORT_PREFIX}342; listen ${PORT_PREFIX}342;
server_name ${MAIN_DOMAIN}; server_name ${MAIN_DOMAIN};
root /usr/share/nginx/html; root /usr/share/nginx/html;
@ -18,6 +20,8 @@ data:
} }
} }
server { server {
access_log /var/log/nginx/access.log json;
listen ${PORT_PREFIX}343; listen ${PORT_PREFIX}343;
server_name ${AUTH_DOMAIN}; server_name ${AUTH_DOMAIN};
root /usr/share/nginx/html; root /usr/share/nginx/html;
@ -29,12 +33,17 @@ data:
} }
} }
server { server {
access_log /var/log/nginx/access.log json;
server_name ${MAIN_DOMAIN}; server_name ${MAIN_DOMAIN};
# access_log /var/log/nginx/access.log json; # access_log /var/log/nginx/access.log json;
listen ${PORT_PREFIX}442 ssl; listen ${PORT_PREFIX}442 ssl;
listen [::]:${PORT_PREFIX}442 ssl ipv6only=on; listen [::]:${PORT_PREFIX}442 ssl ipv6only=on;
ssl_certificate /etc/ssl/private/atat.crt; ssl_certificate /etc/ssl/atat.crt;
ssl_certificate_key /etc/ssl/private/atat.key; ssl_certificate_key /etc/ssl/atat.key;
# additional SSL/TLS settings
include /etc/nginx/snippets/ssl.conf;
location /login-redirect { location /login-redirect {
return 301 https://auth-azure.atat.code.mil$request_uri; return 301 https://auth-azure.atat.code.mil$request_uri;
} }
@ -58,18 +67,20 @@ data:
} }
} }
server { server {
# access_log /var/log/nginx/access.log json; access_log /var/log/nginx/access.log json;
server_name ${AUTH_DOMAIN}; server_name ${AUTH_DOMAIN};
listen ${PORT_PREFIX}443 ssl; listen ${PORT_PREFIX}443 ssl;
listen [::]:${PORT_PREFIX}443 ssl ipv6only=on; listen [::]:${PORT_PREFIX}443 ssl ipv6only=on;
ssl_certificate /etc/ssl/private/atat.crt; ssl_certificate /etc/ssl/atat.crt;
ssl_certificate_key /etc/ssl/private/atat.key; ssl_certificate_key /etc/ssl/atat.key;
# Request and validate client certificate # Request and validate client certificate
ssl_verify_client on; ssl_verify_client on;
ssl_verify_depth 10; ssl_verify_depth 10;
ssl_client_certificate /etc/ssl/client-ca-bundle.pem; ssl_client_certificate /etc/ssl/client-ca-bundle.pem;
# Guard against HTTPS -> HTTP downgrade # additional SSL/TLS settings
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; always"; include /etc/nginx/snippets/ssl.conf;
location / { location / {
return 301 https://azure.atat.code.mil$request_uri; return 301 https://azure.atat.code.mil$request_uri;
} }
@ -88,3 +99,18 @@ data:
uwsgi_param HTTP_X_REQUEST_ID $request_id; 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"'
'}';

View File

@ -5,9 +5,25 @@ metadata:
name: atst-worker-envvars name: atst-worker-envvars
namespace: atat namespace: atat
data: 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 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 PGSSLMODE: verify-full
PGSSLROOTCERT: /opt/atat/atst/ssl/pgsslrootcert.crt 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 SERVER_NAME: azure.atat.code.mil
TZ: UTC TZ: UTC

View File

@ -23,6 +23,7 @@ spec:
labels: labels:
app: atst app: atst
role: web role: web
aadpodidbinding: atat-kv-id-binding
spec: spec:
securityContext: securityContext:
fsGroup: 101 fsGroup: 101
@ -30,12 +31,9 @@ spec:
- name: atst - name: atst
image: $CONTAINER_IMAGE image: $CONTAINER_IMAGE
envFrom: envFrom:
- configMapRef: - configMapRef:
name: atst-envvars name: atst-envvars
volumeMounts: volumeMounts:
- name: atst-config
mountPath: "/opt/atat/atst/atst-overrides.ini"
subPath: atst-overrides.ini
- name: nginx-client-ca-bundle - name: nginx-client-ca-bundle
mountPath: "/opt/atat/atst/ssl/server-certs/ca-chain.pem" mountPath: "/opt/atat/atst/ssl/server-certs/ca-chain.pem"
subPath: client-ca-bundle.pem subPath: client-ca-bundle.pem
@ -49,6 +47,8 @@ spec:
- name: uwsgi-config - name: uwsgi-config
mountPath: "/opt/atat/atst/uwsgi.ini" mountPath: "/opt/atat/atst/uwsgi.ini"
subPath: uwsgi.ini subPath: uwsgi.ini
- name: flask-secret
mountPath: "/config"
- name: nginx - name: nginx
image: nginx:alpine image: nginx:alpine
ports: ports:
@ -62,37 +62,32 @@ spec:
name: auth name: auth
volumeMounts: volumeMounts:
- name: nginx-config - name: nginx-config
mountPath: "/etc/nginx/conf.d/atst.conf" mountPath: "/etc/nginx/conf.d/"
subPath: atst.conf
- name: uwsgi-socket-dir - name: uwsgi-socket-dir
mountPath: "/var/run/uwsgi" mountPath: "/var/run/uwsgi"
- name: nginx-htpasswd - name: nginx-htpasswd
mountPath: "/etc/nginx/.htpasswd" mountPath: "/etc/nginx/.htpasswd"
subPath: .htpasswd subPath: .htpasswd
- name: tls
mountPath: "/etc/ssl/private"
- name: nginx-client-ca-bundle - name: nginx-client-ca-bundle
mountPath: "/etc/ssl/" mountPath: "/etc/ssl/client-ca-bundle.pem"
subPath: "client-ca-bundle.pem"
- name: acme - name: acme
mountPath: "/usr/share/nginx/html/.well-known/acme-challenge/" mountPath: "/usr/share/nginx/html/.well-known/acme-challenge/"
- name: snippets
mountPath: "/etc/nginx/snippets/"
- name: nginx-secret
mountPath: "/etc/ssl/"
volumes: volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: override.ini
path: atst-overrides.ini
mode: 0644
- name: nginx-client-ca-bundle - name: nginx-client-ca-bundle
configMap: configMap:
name: nginx-client-ca-bundle name: nginx-client-ca-bundle
defaultMode: 0666 defaultMode: 0444
items:
- key: "client-ca-bundle.pem"
path: "client-ca-bundle.pem"
- name: nginx-config - name: nginx-config
configMap: configMap:
name: atst-nginx name: atst-nginx
items:
- key: nginx-config
path: atst.conf
- name: uwsgi-socket-dir - name: uwsgi-socket-dir
emptyDir: emptyDir:
medium: Memory medium: Memory
@ -100,19 +95,9 @@ spec:
secret: secret:
secretName: atst-nginx-htpasswd secretName: atst-nginx-htpasswd
items: items:
- key: htpasswd - key: htpasswd
path: .htpasswd path: .htpasswd
mode: 0640 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
- name: crls-vol - name: crls-vol
persistentVolumeClaim: persistentVolumeClaim:
claimName: crls-vol-claim claimName: crls-vol-claim
@ -120,9 +105,9 @@ spec:
configMap: configMap:
name: pgsslrootcert name: pgsslrootcert
items: items:
- key: cert - key: cert
path: pgsslrootcert.crt path: pgsslrootcert.crt
mode: 0666 mode: 0666
- name: acme - name: acme
configMap: configMap:
name: acme-challenges name: acme-challenges
@ -132,9 +117,32 @@ spec:
name: uwsgi-config name: uwsgi-config
defaultMode: 0666 defaultMode: 0666
items: items:
- key: uwsgi.ini - key: uwsgi.ini
path: uwsgi.ini path: uwsgi.ini
mode: 0644 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 apiVersion: extensions/v1beta1
kind: Deployment kind: Deployment
@ -155,47 +163,51 @@ spec:
labels: labels:
app: atst app: atst
role: worker role: worker
aadpodidbinding: atat-kv-id-binding
spec: spec:
securityContext: securityContext:
fsGroup: 101 fsGroup: 101
containers: containers:
- name: atst-worker - name: atst-worker
image: $CONTAINER_IMAGE image: $CONTAINER_IMAGE
args: [ args:
"/opt/atat/atst/.venv/bin/python", [
"/opt/atat/atst/.venv/bin/celery", "/opt/atat/atst/.venv/bin/python",
"-A", "/opt/atat/atst/.venv/bin/celery",
"celery_worker.celery", "-A",
"worker", "celery_worker.celery",
"--loglevel=info" "worker",
] "--loglevel=info",
]
envFrom: envFrom:
- configMapRef: - configMapRef:
name: atst-envvars name: atst-envvars
- configMapRef: - configMapRef:
name: atst-worker-envvars name: atst-worker-envvars
volumeMounts: volumeMounts:
- name: atst-config
mountPath: "/opt/atat/atst/atst-overrides.ini"
subPath: atst-overrides.ini
- name: pgsslrootcert - name: pgsslrootcert
mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt" mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt"
subPath: pgsslrootcert.crt subPath: pgsslrootcert.crt
- name: flask-secret
mountPath: "/config"
volumes: volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: override.ini
path: atst-overrides.ini
mode: 0644
- name: pgsslrootcert - name: pgsslrootcert
configMap: configMap:
name: pgsslrootcert name: pgsslrootcert
items: items:
- key: cert - key: cert
path: pgsslrootcert.crt path: pgsslrootcert.crt
mode: 0666 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 apiVersion: extensions/v1beta1
kind: Deployment kind: Deployment
@ -216,47 +228,51 @@ spec:
labels: labels:
app: atst app: atst
role: beat role: beat
aadpodidbinding: atat-kv-id-binding
spec: spec:
securityContext: securityContext:
fsGroup: 101 fsGroup: 101
containers: containers:
- name: atst-beat - name: atst-beat
image: $CONTAINER_IMAGE image: $CONTAINER_IMAGE
args: [ args:
"/opt/atat/atst/.venv/bin/python", [
"/opt/atat/atst/.venv/bin/celery", "/opt/atat/atst/.venv/bin/python",
"-A", "/opt/atat/atst/.venv/bin/celery",
"celery_worker.celery", "-A",
"beat", "celery_worker.celery",
"--loglevel=info" "beat",
] "--loglevel=info",
]
envFrom: envFrom:
- configMapRef: - configMapRef:
name: atst-envvars name: atst-envvars
- configMapRef: - configMapRef:
name: atst-worker-envvars name: atst-worker-envvars
volumeMounts: volumeMounts:
- name: atst-config
mountPath: "/opt/atat/atst/atst-overrides.ini"
subPath: atst-overrides.ini
- name: pgsslrootcert - name: pgsslrootcert
mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt" mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt"
subPath: pgsslrootcert.crt subPath: pgsslrootcert.crt
- name: flask-secret
mountPath: "/config"
volumes: volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: override.ini
path: atst-overrides.ini
mode: 0644
- name: pgsslrootcert - name: pgsslrootcert
configMap: configMap:
name: pgsslrootcert name: pgsslrootcert
items: items:
- key: cert - key: cert
path: pgsslrootcert.crt path: pgsslrootcert.crt
mode: 0666 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 apiVersion: v1
kind: Service kind: Service
@ -268,12 +284,12 @@ metadata:
spec: spec:
loadBalancerIP: 13.92.235.6 loadBalancerIP: 13.92.235.6
ports: ports:
- port: 80 - port: 80
targetPort: 8342 targetPort: 8342
name: http name: http
- port: 443 - port: 443
targetPort: 8442 targetPort: 8442
name: https name: https
selector: selector:
role: web role: web
type: LoadBalancer type: LoadBalancer
@ -288,12 +304,12 @@ metadata:
spec: spec:
loadBalancerIP: 23.100.24.41 loadBalancerIP: 23.100.24.41
ports: ports:
- port: 80 - port: 80
targetPort: 8343 targetPort: 8343
name: http name: http
- port: 443 - port: 443
targetPort: 8443 targetPort: 8443
name: https name: https
selector: selector:
role: web role: web
type: LoadBalancer type: LoadBalancer

View File

@ -5,9 +5,16 @@ metadata:
namespace: atat namespace: atat
spec: spec:
schedule: "0 * * * *" schedule: "0 * * * *"
concurrencyPolicy: Replace
successfulJobsHistoryLimit: 1
jobTemplate: jobTemplate:
spec: spec:
template: template:
metadata:
labels:
app: atst
role: crl-sync
aadpodidbinding: atat-kv-id-binding
spec: spec:
restartPolicy: OnFailure restartPolicy: OnFailure
containers: containers:
@ -25,19 +32,21 @@ spec:
- configMapRef: - configMapRef:
name: atst-worker-envvars name: atst-worker-envvars
volumeMounts: volumeMounts:
- name: atst-config
mountPath: "/opt/atat/atst/atst-overrides.ini"
subPath: atst-overrides.ini
- name: crls-vol - name: crls-vol
mountPath: "/opt/atat/atst/crls" mountPath: "/opt/atat/atst/crls"
- name: flask-secret
mountPath: "/config"
volumes: volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: override.ini
path: atst-overrides.ini
mode: 0644
- name: crls-vol - name: crls-vol
persistentVolumeClaim: persistentVolumeClaim:
claimName: crls-vol-claim 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

View File

@ -11,3 +11,4 @@ resources:
- nginx-client-ca-bundle.yml - nginx-client-ca-bundle.yml
- acme-challenges.yml - acme-challenges.yml
- aadpodidentity.yml - aadpodidentity.yml
- nginx-snippets.yml

View File

@ -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;

View File

@ -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"

View File

@ -7,6 +7,7 @@ patchesStrategicMerge:
- replica_count.yml - replica_count.yml
- ports.yml - ports.yml
- envvars.yml - envvars.yml
- flex_vol.yml
patchesJson6902: patchesJson6902:
- target: - target:
group: extensions group: extensions

View File

@ -7,6 +7,11 @@ spec:
ttlSecondsAfterFinished: 100 ttlSecondsAfterFinished: 100
backoffLimit: 2 backoffLimit: 2
template: template:
metadata:
labels:
app: atst
role: migration
aadpodidbinding: atat-kv-id-binding
spec: spec:
containers: containers:
- name: migration - name: migration
@ -28,20 +33,12 @@ spec:
- configMapRef: - configMapRef:
name: atst-worker-envvars name: atst-worker-envvars
volumeMounts: volumeMounts:
- name: atst-config
mountPath: "/opt/atat/atst/atst-overrides.ini"
subPath: atst-overrides.ini
- name: pgsslrootcert - name: pgsslrootcert
mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt" mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt"
subPath: pgsslrootcert.crt subPath: pgsslrootcert.crt
- name: flask-secret
mountPath: "/config"
volumes: volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: override.ini
path: atst-overrides.ini
mode: 0644
- name: pgsslrootcert - name: pgsslrootcert
configMap: configMap:
name: pgsslrootcert name: pgsslrootcert
@ -49,4 +46,14 @@ spec:
- key: cert - key: cert
path: pgsslrootcert.crt path: pgsslrootcert.crt
mode: 0666 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 restartPolicy: Never

View File

@ -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
}
}
}
]
}
]
}
}

View File

@ -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)
})
})

View File

@ -11,4 +11,10 @@ export default {
default: false, default: false,
}, },
}, },
methods: {
collapse: function() {
this.isVisible = false
},
},
} }

View File

@ -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())
},
},
}

View File

@ -13,22 +13,14 @@ export default {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
initialOtherValue: String,
optional: Boolean, optional: Boolean,
}, },
data: function() { data: function() {
const showError = (this.initialErrors && this.initialErrors.length) || false
return { return {
showError: showError, showError: this.initialErrors.length > 0,
showValid: !showError && this.initialValue.length > 0, showValid: false,
validationError: this.initialErrors.join(' '), validationError: this.initialErrors.join(' '),
otherChecked: this.initialValue.includes('other')
? true
: this.otherChecked,
otherText: this.initialValue.includes('other')
? this.initialOtherValue
: '',
selections: this.initialValue, selections: this.initialValue,
} }
}, },
@ -36,17 +28,15 @@ export default {
methods: { methods: {
onInput: function(e) { onInput: function(e) {
emitFieldChange(this) emitFieldChange(this)
this.showError = false this.showError = !this.valid
this.showValid = true this.showValid = !this.showError
}, this.validationError = 'This field is required.'
otherToggle: function() {
this.otherChecked = !this.otherChecked
}, },
}, },
computed: { computed: {
valid: function() { valid: function() {
return this.optional || this.showValid return this.optional || this.selections.length > 0
}, },
}, },
} }

View File

@ -1,14 +1,12 @@
import { set } from 'vue/dist/vue' import { set } from 'vue/dist/vue'
import { formatDollars } from '../../lib/dollars' import { formatDollars } from '../../lib/dollars'
import { set as _set } from 'lodash'
export default { export default {
name: 'spend-table', name: 'spend-table',
props: { props: {
applications: Object, applications: Array,
environments: Object,
currentMonthIndex: String,
prevMonthIndex: String,
}, },
data: function() { data: function() {
@ -18,20 +16,16 @@ export default {
}, },
created: function() { created: function() {
Object.keys(this.applications).forEach(application => { this.applicationsState.forEach(application => {
set(this.applicationsState[application], 'isVisible', false) application.isVisible = false
}) })
}, },
methods: { methods: {
toggle: function(e, applicationName) { toggle: function(e, applicationIndex) {
this.applicationsState = Object.assign(this.applicationsState, { set(this.applicationsState, applicationIndex, {
[applicationName]: Object.assign( ...this.applicationsState[applicationIndex],
this.applicationsState[applicationName], isVisible: !this.applicationsState[applicationIndex].isVisible,
{
isVisible: !this.applicationsState[applicationName].isVisible,
}
),
}) })
}, },

View File

@ -58,18 +58,18 @@ export default {
this.$refs.attachmentFilename.value = file.name this.$refs.attachmentFilename.value = file.name
this.$refs.attachmentObjectName.value = response.objectName this.$refs.attachmentObjectName.value = response.objectName
this.$refs.attachmentInput.disabled = true this.$refs.attachmentInput.disabled = true
emitFieldChange(this)
this.changed = true
this.downloadLink = await this.getDownloadLink( this.downloadLink = await this.getDownloadLink(
file.name, file.name,
response.objectName response.objectName
) )
} else { } else {
emitFieldChange(this)
this.changed = true
this.uploadError = true this.uploadError = true
} }
this.changed = true
emitFieldChange(this)
}, },
removeAttachment: function(e) { removeAttachment: function(e) {
e.preventDefault() e.preventDefault()

View File

@ -7,6 +7,8 @@ import Vue from 'vue/dist/vue'
import VTooltip from 'v-tooltip' import VTooltip from 'v-tooltip'
import stickybits from 'stickybits' import stickybits from 'stickybits'
import Accordion from './components/accordion'
import AccordionList from './components/accordion_list'
import dodlogin from './components/dodlogin' import dodlogin from './components/dodlogin'
import optionsinput from './components/options_input' import optionsinput from './components/options_input'
import multicheckboxinput from './components/multi_checkbox_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 ToForm from './components/forms/to_form'
import ClinFields from './components/clin_fields' import ClinFields from './components/clin_fields'
import PopDateRange from './components/pop_date_range' import PopDateRange from './components/pop_date_range'
import Accordion from './components/accordion'
import ToggleMenu from './components/toggle_menu' import ToggleMenu from './components/toggle_menu'
Vue.config.productionTip = false Vue.config.productionTip = false
@ -42,6 +43,7 @@ const app = new Vue({
el: '#app-root', el: '#app-root',
components: { components: {
Accordion, Accordion,
AccordionList,
dodlogin, dodlogin,
toggler, toggler,
optionsinput, optionsinput,

View File

@ -17,6 +17,7 @@ export default {
methods: { methods: {
toggle: function(e) { toggle: function(e) {
e.preventDefault() e.preventDefault()
e.stopPropagation()
this.isVisible = !this.isVisible this.isVisible = !this.isVisible
}, },
}, },

View File

@ -15,7 +15,7 @@ PASSWORD = os.getenv("ATAT_BA_PASSWORD", "")
DISABLE_VERIFY = os.getenv("DISABLE_VERIFY", "true").lower() == "true" DISABLE_VERIFY = os.getenv("DISABLE_VERIFY", "true").lower() == "true"
# Alpha numerics for random entity names # Alpha numerics for random entity names
LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" #pragma: allowlist secret
NEW_PORTFOLIO_CHANCE = 10 NEW_PORTFOLIO_CHANCE = 10
NEW_APPLICATION_CHANCE = 10 NEW_APPLICATION_CHANCE = 10
@ -141,15 +141,8 @@ def create_portfolio(l):
new_portfolio_form = l.client.get("/portfolios/new") new_portfolio_form = l.client.get("/portfolios/new")
new_portfolio_body = { new_portfolio_body = {
"name": f"Load Test Created - {''.join(choices(LETTERS, k=5))}", "name": f"Load Test Created - {''.join(choices(LETTERS, k=5))}",
"defense_component": "Army, Department of the", "defense_component": "army",
"description": "Test", "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), "csrf_token": get_csrf_token(new_portfolio_form),
} }

94
script/integration_tests Executable file
View File

@ -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

View File

@ -13,6 +13,7 @@ SETTINGS=(
AUTH_DOMAIN AUTH_DOMAIN
KV_MI_ID KV_MI_ID
KV_MI_CLIENT_ID KV_MI_CLIENT_ID
TENANT_ID
) )
# Loop all expected settings. Track ones that are missing and build # Loop all expected settings. Track ones that are missing and build

View File

@ -14,6 +14,7 @@ from atst.app import make_config, make_app
from atst.database import db from atst.database import db
from atst.models.application import Application from atst.models.application import Application
from atst.models.clin import JEDICLINType
from atst.models.environment_role import CSPRole from atst.models.environment_role import CSPRole
from atst.domain.application_roles import ApplicationRoles 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.routes.dev import _DEV_USERS as DEV_USERS
from atst.utils import pick
from tests.factories import ( from tests.factories import (
random_service_branch, random_service_branch,
TaskOrderFactory, TaskOrderFactory,
@ -197,7 +200,22 @@ def add_task_orders_to_portfolio(portfolio):
CLINFactory.build( CLINFactory.build(
task_order=expired_to, start_date=(today - five_days), end_date=yesterday 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] task_orders = [draft_to, unsigned_to, upcoming_to, expired_to, active_to]
@ -238,6 +256,7 @@ def add_applications_to_portfolio(portfolio):
None, None,
first_name=user_data["first_name"], first_name=user_data["first_name"],
last_name=user_data["last_name"], last_name=user_data["last_name"],
email=user_data["email"],
) )
app_role = ApplicationRoles.create( app_role = ApplicationRoles.create(
@ -263,7 +282,23 @@ def add_applications_to_portfolio(portfolio):
def create_demo_portfolio(name, data): def create_demo_portfolio(name, data):
try: 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 # auditor = Users.get_by_dod_id("3453453453") # Sally
except NotFoundError: except NotFoundError:
print( print(
@ -281,9 +316,9 @@ def create_demo_portfolio(name, data):
for mock_application in data["applications"]: for mock_application in data["applications"]:
application = Application( 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) envs = Environments.create_many(portfolio.owner, application, env_names)
db.session.add(application) db.session.add(application)
db.session.commit() db.session.commit()
@ -294,8 +329,8 @@ def seed_db():
amanda = Users.get_by_dod_id("2345678901") amanda = Users.get_by_dod_id("2345678901")
# Create Portfolios for Amanda with mocked reporting data # Create Portfolios for Amanda with mocked reporting data
create_demo_portfolio("A-Wing", MockReportingProvider.REPORT_FIXTURE_MAP["A-Wing"]) create_demo_portfolio("A-Wing", MockReportingProvider.FIXTURE_SPEND_DATA["A-Wing"])
create_demo_portfolio("B-Wing", MockReportingProvider.REPORT_FIXTURE_MAP["B-Wing"]) create_demo_portfolio("B-Wing", MockReportingProvider.FIXTURE_SPEND_DATA["B-Wing"])
tie_interceptor = Portfolios.create( tie_interceptor = Portfolios.create(
user=amanda, user=amanda,

View File

@ -45,11 +45,3 @@
@import "sections/application_edit"; @import "sections/application_edit";
@import "sections/reports"; @import "sections/reports";
@import "sections/task_order"; @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;
}

View File

@ -1,28 +1,20 @@
.empty-state { .empty-state {
text-align: center; padding: $gap * 3;
padding: 5rem ($gap * 2) 2rem;
display: flex;
flex-direction: column;
align-items: center;
max-width: 100%; max-width: 100%;
background-color: $color-gray-lightest;
margin-top: $gap * 5;
> .icon { hr {
@include icon-size(50); margin-left: -$gap * 3;
@include icon-color($color-gray-light); margin-right: -$gap * 3;
} }
&__message { &__footer {
font-weight: $font-bold; text-align: center;
}
&__sub-message { a.usa-button {
@include h4; width: 60%;
display: inline-block;
color: $color-gray;
max-width: 100%;
@include media($large-screen) {
@include h3;
} }
} }
} }

View File

@ -1,6 +1,9 @@
// Form Grid // Form Grid
.form-row { .form-row {
margin: ($gap * 4) 0; margin: ($gap * 4) 0;
&--separated {
border-bottom: $color-gray-lighter 1px solid;
}
.form-col { .form-col {
flex-grow: 1; flex-grow: 1;

View File

@ -383,6 +383,8 @@
} }
.portfolio-applications { .portfolio-applications {
margin-top: $gap * 5;
&__header { &__header {
&--title { &--title {
@include subheading; @include subheading;

View File

@ -1,7 +1,7 @@
.sticky-cta { .sticky-cta {
margin-left: -$gap * 4; margin-left: -$gap * 5;
margin-right: -$gap * 5; margin-right: -$gap * 5;
z-index: 10; z-index: 1;
background-color: $color-gray-lightest; background-color: $color-gray-lightest;
border-top: 1px solid $color-gray-lighter; border-top: 1px solid $color-gray-lighter;
border-bottom: 1px solid $color-gray-lighter; border-bottom: 1px solid $color-gray-lighter;

View File

@ -40,8 +40,7 @@
} }
&.col--grow { &.col--grow {
flex: 1; flex: 1 auto;
flex-grow: 1;
padding-right: $spacing-small; padding-right: $spacing-small;
} }

View File

@ -73,6 +73,10 @@
color: $color-green; color: $color-green;
} }
.text-danger {
color: $color-secondary;
}
.user-permission { .user-permission {
font-weight: $font-normal; font-weight: $font-normal;
} }

View File

@ -16,6 +16,7 @@ $footer-height: 5rem;
$usa-banner-height: 2.8rem; $usa-banner-height: 2.8rem;
$sidenav-expanded-width: 25rem; $sidenav-expanded-width: 25rem;
$sidenav-collapsed-width: 10rem; $sidenav-collapsed-width: 10rem;
$max-panel-width: 80rem;
/* /*
* USWDS Variables * USWDS Variables

View File

@ -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 { .accordion {
@include block-list; @include shadow-panel;
margin: $gap * 3 0;
box-shadow: 0 4px 10px 0 rgba(193, 193, 193, 0.5); max-width: $max-panel-width;
margin-bottom: 6 * $gap;
.icon-link {
margin: (-$gap) 0;
}
.icon-link,
.label {
&:first-child {
margin-left: -$gap;
}
&:last-child {
margin-right: -$gap;
}
}
&__header { &__header {
@include block-list-header; padding: $gap * 2 $gap * 3;
background-color: $color-white;
border-top: 3px solid $color-blue; &-text {
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 {
margin: 0; margin: 0;
display: block;
padding: 0 $gap;
text-decoration: none;
} }
} }
&__description { &__button {
@include block-list__description; margin: 0;
font-style: italic;
font-size: $small-font-size;
color: $color-gray;
} }
&__actions { &__content {
margin-top: $gap; padding: 0 ($gap * 3) $gap;
margin-bottom: $gap * 0.5;
display: flex;
flex-direction: row;
.icon-link { &--list-item {
font-size: $small-font-size; border-bottom: 1px solid $color-gray-lightest;
padding: $gap 0;
svg { &:last-child {
width: 1rem; 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 { &--empty {
@include block-list__footer; font-weight: $font-bold;
color: $color-gray-dark;
border-top: 0; padding: $gap * 8;
text-align: center;
} }
} }
&__item { &-list {
@include block-list-item; max-width: $max-panel-width;
opacity: 0.75; &__collapse {
background-color: $color-blue-light; cursor: pointer;
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;
}
}
} }
} }
.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);
}
} }

View File

@ -90,4 +90,8 @@
padding: 2px; padding: 2px;
} }
} }
&--primary {
@include icon-color($color-primary);
}
} }

View File

@ -16,6 +16,16 @@
} }
.jedi-clin-funding { .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-top: $gap * 3;
padding-bottom: $gap * 3; padding-bottom: $gap * 3;
@ -37,14 +47,36 @@
margin: 0; margin: 0;
} }
&__meter { &__graph {
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);
width: 100%; 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 { &-values {
display: flex; display: flex;
@ -52,13 +84,32 @@
} }
&__meta { &__meta {
&--remaining { margin-right: $gap * 5;
margin-left: auto;
text-align: right;
}
&-header { &-header {
@include small-copy; @include small-copy;
margin-bottom: 0; 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 { &-value {
margin-bottom: 0; margin-bottom: 0;
@ -87,4 +138,32 @@
font-size: $lead-font-size; 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;
}
}
}
}
}
} }

View File

@ -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 { .task-order {
margin-top: $gap * 4; margin-top: $gap * 4;
width: 900px; width: 900px;
@ -149,21 +127,6 @@
width: 100%; width: 100%;
} }
.label {
&--pending,
&--started {
background-color: $color-gold;
}
&--active {
background-color: $color-green;
}
&--expired {
background-color: $color-red;
}
}
.task-order-document-link { .task-order-document-link {
&__icon { &__icon {
padding-top: 0.5rem; padding-top: 0.5rem;

View File

@ -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/empty_state.html" import EmptyState %}
{% from "components/sticky_cta.html" import StickyCTA %}
{% from "components/icon.html" import Icon %}
{% extends "portfolios/base.html" %} {% extends "portfolios/base.html" %}
@ -7,85 +10,74 @@
{% block portfolio_content %} {% block portfolio_content %}
{% call StickyCTA(text="common.applications"|translate) %}
{% if can_create_applications and portfolio.applications %}
<a href="{{ url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id) }}" class="usa-button usa-button-primary">
{{ "portfolios.applications.create_button"|translate }}
</a>
{% endif %}
{% endcall %}
<div class='portfolio-applications'> <div class='portfolio-applications'>
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
<div class='portfolio-applications__header row'>
<div class='portfolio-applications__header--title col col--grow'>Applications</div>
<div class='portfolio-applications__header--actions col'>
{% if can_create_applications %}
<a class='icon-link' href='{{ url_for('applications.view_new_application_step_1', portfolio_id=portfolio.id) }}'>
{{ 'portfolios.applications.add_application_text' | translate }}
{{ Icon("plus", classes="sidenav__link-icon") }}
</a>
{% endif %}
</div>
</div>
{% if not portfolio.applications %} {% if not portfolio.applications %}
{{ EmptyState( {{ EmptyState(
'This portfolio doesnt have any applications', header="portfolios.applications.empty_state.header"|translate,
action_label='Add a new application' if can_create_applications else None, message="portfolios.applications.empty_state.message"|translate,
action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None, button_text="portfolios.applications.empty_state.button_text"|translate,
icon='cloud', button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id),
sub_message=None if can_create_applications else 'Please contact your JEDI Cloud portfolio administrator to set up a new application.', view_only_text="portfolios.applications.empty_state.view_only_text"|translate,
add_perms=can_create_applications user_can_create=can_create_applications,
) }} ) }}
{% else %} {% else %}
{% call AccordionList() %}
<div class='application-list'>
{% for application in portfolio.applications|sort(attribute='name') %} {% for application in portfolio.applications|sort(attribute='name') %}
{% set section_name = "application-{}".format(application.id) %} {% set section_name = "application-{}".format(application.id) %}
{% set title = "Environments ({})".format(application.environments|length) %}
<toggler inline-template> <div class="accordion">
<div class='accordion application-list-item'> <div class="accordion__header">
<header class='accordion__header row'> <h3 class="accordion__header-text">
<div class='col col-grow'> <a href='{{ url_for("applications.settings", application_id=application.id) }}'>
<h3 class='icon-link accordion__title' v-on:click="toggleSection('{{ section_name }}')">{{ application.name }}</h3> {{ application.name }} {{ Icon("caret_right", classes="icon--tiny icon--primary") }}
<p class='accordion__description'> </a>
{{ application.description }} </h3>
</p> <p class="accordion__header-text">
<div class='accordion__actions'> {{ application.description }}
<a class='icon-link' href='{{ url_for("applications.settings", application_id=application.id) }}'> </p>
<span>{{ "portfolios.applications.app_settings_text" | translate }}</span> </div>
</a> {% call Accordion(
<div class='separator'></div> title=title,
{% set has_environments = 0 < (application.environments|length) %} id=section_name,
<a class='icon-link triangle-box' v-on:click="toggleSection('{{ section_name }}')" disabled="{{ not has_environments }}"> heading_tag="h4"
<span>Environments ({{ application.environments|length }})</span> ) %}
{% if has_environments %} {% for environment in application.environments %}
<span v-if="selectedSection === '{{ section_name }}'"> {% set env_access = environment_access[environment.id] %}
{{ Icon('caret_up') }} <div class="accordion__content--list-item">
</span> <div class="row">
<span v-else> <div class="col col--grow">
{{ Icon('caret_down') }} {% if env_access %}
</span> <a href='{{ url_for("applications.access_environment", environment_id=environment.id)}}' target='_blank' rel='noopener noreferrer'>
<div class="triangle-up" v-if="selectedSection === '{{ section_name }}'"></div> {{ environment.displayname }} {{ Icon('link', classes='icon--medium icon--primary') }}
</a>
{% else %}
{{ environment.displayname }}
{% endif %} {% endif %}
</a> </div>
{% if env_access %}
<div class="col">
{{ env_access }}
</div>
{% endif %}
</div> </div>
</div> </div>
</header> {% endfor %}
<ul v-show="selectedSection === '{{ section_name }}'"> {% endcall %}
{% for environment in application.environments %} </div>
<li class='accordion__item application-list-item__environment'>
<div class='application-list-item__environment__name'>
<span>{{ environment.displayname }}</span>
</div>
{% if g.current_user in environment.users %}
<a href='{{ url_for("applications.access_environment", environment_id=environment.id)}}' target='_blank' rel='noopener noreferrer' class='application-list-item__environment__csp_link icon-link'>
<span>{{ "portfolios.applications.csp_console_text" | translate }}</span>
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</toggler>
{% endfor %} {% endfor %}
</div> {% endcall %}
{% endif %} {% endif %}
</div> </div>

View File

@ -1,23 +1,30 @@
{% macro Accordion(title, id, heading_level="h2") %} {% macro Accordion(
<accordion inline-template> title,
<div> id,
<{{heading_level}}> wrapper_tag="div",
<button wrapper_classes="",
v-on:click="toggle($event)" heading_tag="h2",
class="usa-accordion-button" heading_classes="",
aria-controls="{{ id }}" content_tag="div",
v-bind:aria-expanded= "isVisible ? 'true' : 'false'" content_classes="") %}
> <accordion v-cloak inline-template>
{{ title }} <{{wrapper_tag}} class="{{ wrapper_classes }}">
</button> <{{heading_tag}} class="accordion__button {{ heading_classes }}">
</{{heading_level}}> <button
<div v-on:click="toggle($event)"
id="{{ id }}" class="usa-accordion-button"
class="usa-accordion-content" aria-controls="{{ id }}"
v-bind:aria-hidden="isVisible ? 'false' : 'true'" v-bind:aria-expanded= "isVisible ? 'true' : 'false'"
> >
{{ caller() }} {{ title }}
</div> </button>
</div> </{{heading_tag}}>
</accordion> <{{content_tag}}
id="{{ id }}"
class="usa-accordion-content accordion__content {{ content_classes }}"
v-bind:aria-hidden="isVisible ? 'false' : 'true'">
{{ caller() }}
</{{content_tag}}>
</{{wrapper_tag}}>
</accordion>
{% endmacro %} {% endmacro %}

View File

@ -0,0 +1,11 @@
{% macro AccordionList() %}
<accordion-list inline-template>
<div class="accordion-list usa-accordion">
<div class="action-group">
<a v-on:click="handleClick($event)" class="accordion-list__collapse">Collapse All</a>
</div>
<!-- caller iterates over accordion vue components or Accordion jinja macros -->
{{ caller() }}
</div>
</accordion-list>
{% endmacro %}

View File

@ -1,20 +1,14 @@
{% from "components/icon.html" import Icon %} {% macro EmptyState(header, message, button_text, button_link, view_only_text, user_can_create=True) %}
<div class="empty-state">
{% macro EmptyState(message, action_label, action_href, icon=None, sub_message=None, add_perms=True) -%} <h3>{{ header }}</h3>
<div class='empty-state'> <p>{{ message }}</p>
<p class='empty-state__message'>{{ message }}</p> <hr>
<div class="empty-state__footer">
{% if icon %} {% if user_can_create %}
{{ Icon(icon) }} <a href="{{ button_link }}" class="usa-button usa-button-primary">{{ button_text }}</a>
{% endif %} {% else %}
<p>{{ view_only_text }}</p>
{% if sub_message %} {% endif %}
<p class='empty-state__sub-message'>{{ sub_message }}</p> </div>
{% endif %}
{% if add_perms and (action_href and action_label) %}
<a href='{{ action_href }}' class='usa-button usa-button-big'>{{ action_label }}</a>
{% endif %}
</div> </div>
{%- endmacro %} {% endmacro %}

View File

@ -45,19 +45,8 @@
<ul> <ul>
{% for choice in field.choices %} {% for choice in field.choices %}
<li> <li>
{% if choice[0] != 'other' %}
<input type='checkbox' name='{{ field.name }}' id='{{ field.name }}-{{ loop.index0 }}' value='{{ choice[0] }}' v-model="selections"/> <input type='checkbox' name='{{ field.name }}' id='{{ field.name }}-{{ loop.index0 }}' value='{{ choice[0] }}' v-model="selections"/>
<label for='{{ field.name }}-{{ loop.index0 }}'>{{ choice[1] | safe }}</label> <label for='{{ field.name }}-{{ loop.index0 }}'>{{ choice[1] | safe }}</label>
{% else %}
<input @click="otherToggle" type='checkbox' name='{{ field.name }}' id='{{ field.name }}-{{ loop.index0 }}' value='other' v-model="selections"/>
<label for='{{ field.name }}-{{ loop.index0 }}'>{{ choice[1] | safe }}</label>
{% if other_input_field %}
<div v-show="otherChecked">
<input type='text' name='{{ other_input_field.name}}' id='{{ field.name }}-other' v-model:value="otherText" aria-expanded='false' />
</div>
{% endif %}
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -14,7 +14,7 @@
{% endif %} {% endif %}
{% call StickyCTA(sticky_header) %} {% call StickyCTA(sticky_header) %}
<a href="{{ url_for("portfolios.new_portfolio") }}" class="usa-button-primary"> <a href="{{ url_for("portfolios.new_portfolio_step_1") }}" class="usa-button-primary">
{{ "home.add_portfolio_button_text" | translate }} {{ "home.add_portfolio_button_text" | translate }}
</a> </a>
{% endcall %} {% endcall %}

View File

@ -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 %}

View File

@ -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 %}
<main class="usa-section usa-content">
{% include "fragments/flash.html" %}
<h1>New Portfolio Form</h1>
<base-form inline-template>
<form class="panel__content" id="portfolio-create" action="{{ url_for('portfolios.create_portfolio') }}" method="POST">
{{ form.csrf_token }}
{{ TextInput(form.name, optional=False) }}
{{ OptionsInput(form.defense_component, optional=False) }}
{{ TextInput(form.description, paragraph=True) }}
<h3 id="reporting" class="subheading">{{ "task_orders.new.app_info.project_title" | translate }}</h3>
<hr>
{{ OptionsInput(form.app_migration) }}
{{ OptionsInput(form.native_apps) }}
<p>{{ "forms.task_order.native_apps.not_sure_help" | translate }}</p>
{{ MultiCheckboxInput(form.complexity, form.complexity_other) }}
<hr>
<h3 class="subheading">{{ "task_orders.new.app_info.team_title" | translate }}</h3>
<p>{{ "task_orders.new.app_info.subtitle" | translate }}</p>
{{ MultiCheckboxInput(form.dev_team, form.dev_team_other) }}
{{ OptionsInput(form.team_experience) }}
<hr>
<div class='action-group'>
{{
SaveButton(
text=('common.save' | translate),
form="portfolio-create",
element="input",
)
}}
</div>
</form>
</base-form>
</main>
{% endblock %}

View File

@ -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 %}
<main class="usa-section usa-content">
{% include "fragments/flash.html" %}
<div class='portfolio-header__name'>
<p>{{ "portfolios.header" | translate }}</p>
<h1>{{ "New Portfolio" }}</h1>
</div>
{{ StickyCTA(text="Create New Portfolio") }}
<base-form inline-template>
<form id="portfolio-create" action="{{ url_for('portfolios.create_portfolio') }}" method="POST">
{{ form.csrf_token }}
<div class="form-row form-row--separated">
<div class="form-col">
{{ TextInput(form.name, optional=False) }}
{{"forms.portfolio.name.help_text" | translate | safe }}
</div>
</div>
<div class="form-row form-row--separated">
<div class="form-col">
{{ TextInput(form.description, paragraph=True) }}
{{"forms.portfolio.description.help_text" | translate | safe }}
</div>
</div>
<div class="form-row">
<div class="form-col">
{{ MultiCheckboxInput(form.defense_component, optional=False) }}
{{ "forms.portfolio.defense_component.help_text" | translate | safe }}
</div>
</div>
<div class='action-group'>
{{
SaveButton(
text=('common.save' | translate),
form="portfolio-create",
element="input",
)
}}
</div>
</form>
</base-form>
</main>
{% endblock %}

View File

@ -3,9 +3,6 @@
<div> <div>
<h2>Funds Expended per Application and Environment</h2> <h2>Funds Expended per Application and Environment</h2>
{% set current_month_index = current_month.strftime('%m/%Y') %}
{% set prev_month_index = prev_month.strftime('%m/%Y') %}
{% if not portfolio.applications %} {% if not portfolio.applications %}
{% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %} {% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %}
@ -15,20 +12,16 @@
%} %}
{{ EmptyState( {{ EmptyState(
('portfolios.reports.empty_state.message' | translate), header='portfolios.reports.empty_state.message' | translate,
action_label= ('portfolios.reports.empty_state.action_label' | translate) if can_create_applications else None, message=message,
action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None, button_text="portfolios.applications.empty_state.button_text"|translate,
icon='chart', button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id),
sub_message=message, view_only_text="portfolios.applications.empty_state.view_only_text"|translate,
add_perms=can_create_applications user_can_create=can_create_applications,
) }} ) }}
{% else %} {% else %}
<spend-table <spend-table v-bind:applications='{{ monthly_spending | tojson }}' inline-template>
v-bind:applications='{{ monthly_totals['applications'] | tojson }}'
v-bind:environments='{{ monthly_totals['environments'] | tojson }}'
current-month-index='{{ current_month_index }}'
prev-month-index='{{ prev_month_index }}'
inline-template>
<div class="responsive-table-wrapper"> <div class="responsive-table-wrapper">
<table class="atat-table"> <table class="atat-table">
<thead> <thead>
@ -40,37 +33,41 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for='(application, name) in applicationsState'> <template v-for='(application, applicationIndex) in applicationsState'>
<tr> <tr>
<td> <td>
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large'> <button v-on:click='toggle($event, applicationIndex)' class='icon-link icon-link--large'>
<span v-html='name'></span> <span v-html='application.name'></span>
<template v-if='application.isVisible'>{{ Icon('caret_down') }}</template> <template v-if='application.isVisible'>{{ Icon('caret_down') }}</template>
<template v-else>{{ Icon('caret_up') }}</template> <template v-else>{{ Icon('caret_up') }}</template>
</button> </button>
</td> </td>
<td class="table-cell--align-right"> <td class="table-cell--align-right">
<span v-html='formatDollars(application[currentMonthIndex] || 0)'></span> <span v-html='formatDollars(application.this_month || 0)'></span>
</td> </td>
<td class="table-cell--align-right"> <td class="table-cell--align-right">
<span v-html='formatDollars(application[prevMonthIndex] || 0)'></span> <span v-html='formatDollars(application.last_month || 0)'></span>
</td> </td>
<td class="table-cell--align-right"> <td class="table-cell--align-right">
<span v-html='formatDollars(application["total_spend_to_date"])'></span> <span v-html='formatDollars(application.total)'></span>
</td> </td>
</tr> </tr>
<tr v-for='(environment, envName) in environments[name]' v-show='application.isVisible'> <tr
v-show='application.isVisible'
v-for='(environment, index) in application.environments'
v-bind:class="[ index == application.environments.length -1 ? 'reporting-spend-table__env-row--last' : '']"
>
<td> <td>
<span v-html='envName'></span> <span class="reporting-spend-table__env-row-label" v-html='environment.name'></span>
</td> </td>
<td class="table-cell--align-right"> <td class="table-cell--align-right">
<span v-html='formatDollars(environment[currentMonthIndex] || 0)'></span> <span v-html='formatDollars(environment.this_month || 0)'></span>
</td> </td>
<td class="table-cell--align-right"> <td class="table-cell--align-right">
<span v-html='formatDollars(environment[prevMonthIndex] || 0)'></span> <span v-html='formatDollars(environment.last_month || 0)'></span>
</td> </td>
<td class="table-cell--align-right"> <td class="table-cell--align-right">
<span v-html='formatDollars(environment["total_spend_to_date"])'></span> <span v-html='formatDollars(environment.total)'></span>
</td> </td>
</tr> </tr>
</template> </template>

View File

@ -1,34 +1,51 @@
{% from "components/accordion.html" import Accordion %} {% from "components/accordion.html" import Accordion %}
{% from "components/icon.html" import Icon %}
<section> <section>
<div class="usa-accordion"> <div class="usa-accordion">
{% call Accordion("Expired Task Orders", "expired_task_orders", "h3") %} {% call Accordion(
{% for task_order in expired_task_orders %} "Expired funding",
<a href="{{ url_for("task_orders.review_task_order", task_order_id=task_order["id"]) }}"> "expired_funding",
Task Order {{ task_order["number"] }} heading_classes="reporting-expended-funding__header",
</a> content_tag="table",
<div> content_classes="atat-table reporting-expended-funding__content") %}
<p>Period of Performance</p> <thead>
<p> <tr>
{{ task_order["period_of_performance"].start_date | formattedDate(formatter="%B %d, %Y") }} <th>TO CLIN</th>
- <th>PoP</th>
{{ task_order["period_of_performance"].end_date | formattedDate(formatter="%B %d, %Y") }} <th>CLIN Value</th>
</p> <th>Amount Obligated</th>
</div> <th>Amount Unspent</th>
<div> </tr>
<p>Total Obligated</p> </thead>
<p>{{ task_order["total_obligated_funds"] | dollars }}</p> <tbody>
</div> {% for task_order in expired_task_orders %}
<div> <tr>
<p>Total Expended</p> <td colspan="5">
<p>{{ task_order["expended_funds"] | dollars }}</p> <span class="h4 reporting-expended-funding__header">Task Order</span> <a href="{{ url_for("task_orders.review_task_order", task_order_id=task_order.id) }}">
</div> {{ task_order.number }} {{ Icon("caret_right", classes="icon--tiny icon--blue" ) }}
<div> </a>
<p>Total Unused</p> </td>
<p>{{ (task_order["total_obligated_funds"] - task_order["expended_funds"]) | dollars }}</p> </tr>
</div> {% for clin in task_order.clins %}
{% endfor %} <tr>
<td>
<div class="h4 reporting-expended-funding__header">{{ clin.number }}</div>
<div>{{ ("{}".format(clin.jedi_clin_type) | translate)[15:] }}</div>
</td>
<td>
{{ clin.start_date | formattedDate(formatter="%b %d, %Y") }}
-
{{ clin.end_date | formattedDate(formatter="%b %d, %Y") }}
</td>
<td>{{ clin.total_amount | dollars }}</td>
<td>{{ clin.obligated_amount | dollars }}</td>
<td>{{ 0 | dollars }}</td>
<tr>
{% endfor %}
{% endfor %}
</tbody>
{% endcall %} {% endcall %}
</div> </div>
</section> </section>

View File

@ -5,7 +5,9 @@
{% block portfolio_content %} {% block portfolio_content %}
{{ StickyCTA("Reports") }} {{ StickyCTA("Reports") }}
<div class="portfolio-reports col col--grow"> <div class="portfolio-reports col col--grow">
{% include "fragments/flash.html" %}
<p class="row estimate-warning">{{ "portfolios.reports.estimate_warning" | translate }}</p> <p class="row estimate-warning">{{ "portfolios.reports.estimate_warning" | translate }}</p>
{% include "portfolios/reports/portfolio_summary.html" %} {% include "portfolios/reports/portfolio_summary.html" %}
<hr> <hr>

View File

@ -3,28 +3,61 @@
<section> <section>
<header class="reporting-section-header"> <header class="reporting-section-header">
<h2 class="reporting-section-header__header">Current Obligated funds</h2> <h2 class="reporting-section-header__header">Current Obligated funds</h2>
<span class="reporting-section-header__subheader">As of {{ now | formattedDate(formatter="%B %d, %Y at %H:%M") }}</span> <span class="reporting-section-header__subheader">As of {{ retrieved | formattedDate(formatter="%B %d, %Y at %H:%M") }}</span>
</header> </header>
<div class='panel'> <div class='panel'>
<div class='panel__content jedi-clin-funding'> <div class='panel__content jedi-clin-funding'>
{% for JEDI_clin, funds in current_obligated_funds.items() %} {% for JEDI_clin in current_obligated_funds | sort(attribute='name')%}
{% set remaining_funds = (funds["obligated_funds"] - funds["expended_funds"]) %}
<div class="jedi-clin-funding__clin-wrapper"> <div class="jedi-clin-funding__clin-wrapper">
<h3 class="h5 jedi-clin-funding__header"> <h3 class="h5 jedi-clin-funding__header">
{{ "JEDICLINType.{}".format(JEDI_clin) | translate }} {{ "JEDICLINType.{}".format(JEDI_clin.name) | translate }}
</h3> </h3>
<p class="jedi-clin-funding__subheader">Total obligated amount: {{ funds["obligated_funds"] | dollars }}</p> <p class="jedi-clin-funding__subheader">Total obligated amount: {{ JEDI_clin.obligated | dollars }}</p>
<meter class="jedi-clin-funding__meter" value='{{remaining_funds}}' min='0' max='{{ funds["obligated_funds"] }}' title='{{ JEDI_clin }}'> <div class="jedi-clin-funding__graph">
<div class='jedi-clin-funding__meter-fallback' style='width:{{ (funds["expended_funds"] / funds["obligated_funds"]) * 100 }}%;'></div> {% if JEDI_clin.remaining < 0 %}
</meter> <span style="width:100%" class="jedi-clin-funding__graph-bar jedi-clin-funding__graph-bar--insufficient"></span>
<div class="jedi-clin-funding__meter-values"> {% else %}
{% set invoiced_width = (JEDI_clin.invoiced, JEDI_clin.obligated) | obligatedFundingGraphWidth %}
{% if invoiced_width %}
<span style="width:{{ invoiced_width }}%"
class="jedi-clin-funding__graph-bar jedi-clin-funding__graph-bar--invoiced">
</span>
{% endif %}
{% set estimated_width = (JEDI_clin.estimated, JEDI_clin.obligated) | obligatedFundingGraphWidth %}
{% if estimated_width %}
<span style="width:{{ (JEDI_clin.estimated, JEDI_clin.obligated) | obligatedFundingGraphWidth }}%"
class="jedi-clin-funding__graph-bar jedi-clin-funding__graph-bar--estimated">
</span>
{% endif %}
<span style="width:{{ (JEDI_clin.remaining, JEDI_clin.obligated) | obligatedFundingGraphWidth }}%"
class="jedi-clin-funding__graph-bar jedi-clin-funding__graph-bar--remaining">
</span>
{% endif %}
</div>
<div class="jedi-clin-funding__graph-values">
<div class="jedi-clin-funding__meta"> <div class="jedi-clin-funding__meta">
<p class="jedi-clin-funding__meta-header">Funds expended:</p> <p class="jedi-clin-funding__meta-header">
<p class="h3 jedi-clin-funding__meta-value">{{ funds["expended_funds"] | dollars }}</p> <span class="jedi-clin-funding__meta-key jedi-clin-funding__meta-key--invoiced"></span>
Invoiced expended funds:
</p>
<p class="h3 jedi-clin-funding__meta-value">{{ JEDI_clin.invoiced | dollars }}</p>
</div> </div>
<div class="jedi-clin-funding__meta jedi-clin-funding__meta--remaining"> <div class="jedi-clin-funding__meta">
<p class="jedi-clin-funding__meta-header">Remaining funds:</p> <p class="jedi-clin-funding__meta-header">
<p class="h3 jedi-clin-funding__meta-value">{{ remaining_funds | dollars }}</p> <span class="jedi-clin-funding__meta-key jedi-clin-funding__meta-key--estimated"></span>
Estimated expended funds:
</p>
<p class="h3 jedi-clin-funding__meta-value">{{ JEDI_clin.estimated | dollars }}</p>
</div>
<div class="jedi-clin-funding__meta">
<p class="jedi-clin-funding__meta-header">
<span class="jedi-clin-funding__meta-key jedi-clin-funding__meta-key--{{"remaining" if JEDI_clin.remaining > 0 else "insufficient"}}"></span>
Remaining funds:
</p>
<p class="h3 jedi-clin-funding__meta-value {% if JEDI_clin.remaining < 0 %}text-danger{% endif %}">{{ JEDI_clin.remaining | dollars }}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,3 +1,5 @@
{% from "components/accordion.html" import Accordion %}
{% from "components/accordion_list.html" import AccordionList %}
{% from "components/empty_state.html" import EmptyState %} {% from "components/empty_state.html" import EmptyState %}
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% from "components/sticky_cta.html" import StickyCTA %} {% from "components/sticky_cta.html" import StickyCTA %}
@ -6,96 +8,68 @@
{% block portfolio_content %} {% block portfolio_content %}
{% macro TaskOrderButton(task_order, route, text="Edit", secondary=False) %}
<a href="{{ url_for(route, task_order_id=task_order.id) }}" class="usa-button {{ 'usa-button-secondary' if secondary else '' }}">
{{ text }}
</a>
{% endmacro %}
{% macro TaskOrderDateTime(dt, className="") %} {% macro TaskOrderDateTime(dt, className="") %}
<local-datetime timestamp="{{ dt }}" format="MMMM D, YYYY" class="{{ className }}"></local-datetime> <local-datetime timestamp="{{ dt }}" format="MMMM D, YYYY" class="{{ className }}"></local-datetime>
{% endmacro %} {% endmacro %}
{% macro TaskOrderDate(task_order) %}
<span class="datetime">
<!-- Draft: {Begins, Began} start_date -->
<!-- Everything else: {Starts, Started} start_date | {Ends, Ended} end_date -->
{% if task_order.is_draft %} {% macro TaskOrderList(task_orders, status) %}
{% if task_order.has_begun %} <div class="accordion">
Started on {% call Accordion(title=("task_orders.status_list_title"|translate({'status': status})), id=status, heading_tag="h4") %}
{% if task_orders|length > 0 %}
{% for task_order in task_orders %}
{% set to_number %}
{% if task_order.number != None %}
Task Order #{{ task_order.number }}
{% else %}
New Task Order
{% endif %}
{% endset %}
<div class="accordion__content--list-item">
<h4><a href="{{ url_for('task_orders.review_task_order', task_order_id=task_order.id) }}">{{ to_number }} {{ Icon("caret_right", classes="icon--tiny icon--primary" ) }}</a></h4>
{% if status != 'Expired' -%}
<div class="row">
<div class="col col--grow">
<h5>
Current Period of Performance
</h5>
<p>
{{ task_order.start_date | formattedDate(formatter="%b %d, %Y") }}
-
{{ task_order.end_date | formattedDate(formatter="%b %d, %Y") }}
</p>
</div>
<div class="col col--grow">
<h5>Total Value</h5>
<p>{{ task_order.total_contract_amount | dollars }}</p>
</div>
<div class="col col--grow">
<h5>Total Obligated</h5>
<p>{{ task_order.total_obligated_funds | dollars }}</p>
</div>
<div class="col col--grow">
<h5>Total Expended</h5>
<p>{{ task_order.invoiced_funds | dollars }}</p>
</div>
</div>
{%- endif %}
</div>
{% endfor %}
{% else %} {% else %}
Starts on <div class="accordion__content--empty">
{% endif %} {{ "task_orders.status_empty_state" | translate({ 'status': status }) }}
{{ TaskOrderDateTime(task_order.time_created) }}
{% else %}
{% if task_order.has_begun %}
Began
{% else %}
Begins
{% endif %}
{{ TaskOrderDateTime(task_order.start_date) }}
{% endif %}
{% if not task_order.is_draft %}
&nbsp;&nbsp;|&nbsp;&nbsp;
{% if task_order.has_ended %}
Ended
{% else %}
Ends
{% endif %}
{{ TaskOrderDateTime(task_order.end_date) }}
{% endif %}
</span>
{% endmacro %}
{% macro TaskOrderActions(task_order) %}
<div class="task-order-card__buttons">
{% if task_order.is_draft and user_can(permissions.EDIT_TASK_ORDER_DETAILS) %}
{{ TaskOrderButton(task_order, "task_orders.edit")}}
{% elif task_order.is_expired %}
{{ TaskOrderButton(task_order, "task_orders.review_task_order", text="View") }}
{% elif task_order.is_unsigned %}
{% if user_can(permissions.EDIT_TASK_ORDER_DETAILS) %}
{{ TaskOrderButton(task_order, "task_orders.form_step_four_review", text="Sign", secondary=True) }}
{% endif %}
{{ TaskOrderButton(task_order, "task_orders.review_task_order", text="View") }}
{% endif %}
</div>
{% endmacro %}
{% macro TaskOrderList(task_orders, label='success') %}
<div class="task-order-list">
{% for task_order in task_orders %}
<div class="card task-order-card">
<div class="card__status">
<span class='label label--{{ label_colors[task_order.status] }}'>{{ task_order.display_status }}</span>
{{ TaskOrderDate(task_order) }}
<span class="card__status-spacer"></span>
<span class="card__button">
{{ TaskOrderActions(task_order) }}
</span>
</div> </div>
<div class="card__header"> {% endif %}
<h3>Task Order #{{ task_order.number }}</h3> {% endcall %}
</div>
<div class="card__body">
<b>Total amount: </b>{{ task_order.total_contract_amount | dollars }}
</div>
<div class="card__body">
<b>Obligated amount: </b>{{ task_order.total_obligated_funds | dollars }}
</div>
</div>
{% endfor %}
</div> </div>
{% endmacro %} {% endmacro %}
{% call StickyCTA(text="Funding") %} {% call StickyCTA(text="common.task_orders"|translate) %}
{% if user_can(permissions.CREATE_TASK_ORDER) %} {% if user_can(permissions.CREATE_TASK_ORDER) and task_orders %}
<a href="{{ url_for("task_orders.form_step_one_add_pdf", portfolio_id=portfolio.id) }}" class="usa-button usa-button-primary" type="submit">Start a new task order</a> <a href="{{ url_for("task_orders.form_step_one_add_pdf", portfolio_id=portfolio.id) }}" class="usa-button usa-button-primary">
{{ "task_orders.add_new_button" | translate }}
</a>
{% endif %} {% endif %}
{% endcall %} {% endcall %}
@ -103,15 +77,20 @@
<div class="portfolio-funding"> <div class="portfolio-funding">
{% if task_orders %} {% if to_count > 0 %}
{{ TaskOrderList(task_orders) }} {% call AccordionList() %}
{% for status, to_list in task_orders.items() %}
{{ TaskOrderList(to_list, status) }}
{% endfor %}
{% endcall %}
{% else %} {% else %}
{{ EmptyState( {{ EmptyState(
'This portfolio doesnt have any active or pending task orders.', header="task_orders.empty_state.header"|translate,
action_label='Add a New Task Order', message="task_orders.empty_state.message"|translate,
action_href=url_for('task_orders.form_step_one_add_pdf', portfolio_id=portfolio.id), button_link=url_for('task_orders.form_step_one_add_pdf', portfolio_id=portfolio.id),
icon='cloud', button_text="task_orders.empty_state.button_text"|translate,
add_perms=user_can(permissions.CREATE_TASK_ORDER) view_only_text="task_orders.empty_state.view_only_text"|translate,
user_can_create=user_can(permissions.CREATE_TASK_ORDER),
) }} ) }}
{% endif %} {% endif %}
</div> </div>

1
terraform/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.terraform

View File

@ -0,0 +1,35 @@
resource "azurerm_resource_group" "k8s" {
name = "${var.name}-${var.environment}-vpc"
location = var.region
}
resource "azurerm_kubernetes_cluster" "k8s" {
name = "${var.name}-${var.environment}-k8s"
location = azurerm_resource_group.k8s.location
resource_group_name = azurerm_resource_group.k8s.name
dns_prefix = var.k8s_dns_prefix
service_principal {
client_id = "f05a4457-bd5e-4c63-98e1-89aab42645d0"
client_secret = "19b69e2c-9f55-4850-87cb-88c67a8dc811"
}
default_node_pool {
name = "default"
vm_size = "Standard_D1_v2"
os_disk_size_gb = 30
vnet_subnet_id = var.vnet_subnet_id
node_count = 1
}
lifecycle {
ignore_changes = [
default_node_pool.0.node_count
]
}
tags = {
environment = var.environment
owner = var.owner
}
}

View File

View File

@ -0,0 +1,35 @@
variable "region" {
type = string
description = "Region this module and resources will be created in"
}
variable "name" {
type = string
description = "Unique name for the services in this module"
}
variable "environment" {
type = string
description = "Environment these resources reside (prod, dev, staging, etc)"
}
variable "owner" {
type = string
description = "Owner of the environment and resources created in this module"
}
variable "k8s_dns_prefix" {
type = string
description = "A DNS prefix"
}
variable "k8s_node_size" {
type = string
description = "The size of the instance to use in the node pools for k8s"
default = "Standard_A1_v2"
}
variable "vnet_subnet_id" {
description = "Subnet to use for the default k8s pool"
type = string
}

View File

@ -0,0 +1,40 @@
data "azurerm_client_config" "current" {}
resource "azurerm_resource_group" "keyvault" {
name = "${var.name}-${var.environment}-rg"
location = var.region
}
resource "azurerm_key_vault" "keyvault" {
name = "${var.name}-${var.environment}-keyvault"
location = azurerm_resource_group.keyvault.location
resource_group_name = azurerm_resource_group.keyvault.name
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "premium"
tags = {
environment = var.environment
owner = var.owner
}
}
resource "azurerm_key_vault_access_policy" "keyvault" {
key_vault_id = azurerm_key_vault.keyvault.id
tenant_id = "b5ab0e1e-09f8-4258-afb7-fb17654bc5b3"
object_id = "2ca63d41-d058-4e06-aef6-eb517a53b631"
key_permissions = [
"get",
"list",
"create",
]
secret_permissions = [
"get",
"list",
"set",
]
}

View File

@ -0,0 +1,24 @@
variable "region" {
type = string
description = "Region this module and resources will be created in"
}
variable "name" {
type = string
description = "Unique name for the services in this module"
}
variable "environment" {
type = string
description = "Environment these resources reside (prod, dev, staging, etc)"
}
variable "owner" {
type = string
description = "Owner of this environment"
}
variable "tenant_id" {
type = string
description = "The Tenant ID"
}

View File

@ -0,0 +1,37 @@
resource "azurerm_resource_group" "sql" {
name = "${var.name}-${var.environment}-postgres"
location = var.region
}
resource "azurerm_postgresql_server" "sql" {
name = "${var.name}-${var.environment}-sql"
location = azurerm_resource_group.sql.location
resource_group_name = azurerm_resource_group.sql.name
sku {
name = var.sku_name
capacity = var.sku_capacity
tier = var.sku_tier
family = var.sku_family
}
storage_profile {
storage_mb = var.storage_mb
backup_retention_days = var.storage_backup_retention_days
geo_redundant_backup = var.storage_geo_redundant_backup
auto_grow = var.storage_auto_grow
}
administrator_login = var.administrator_login
administrator_login_password = var.administrator_login_password
version = var.postgres_version
ssl_enforcement = var.ssl_enforcement
}
resource "azurerm_postgresql_virtual_network_rule" "sql" {
name = "${var.name}-${var.environment}-rule"
resource_group_name = azurerm_resource_group.sql.name
server_name = azurerm_postgresql_server.sql.name
subnet_id = var.subnet_id
ignore_missing_vnet_service_endpoint = true
}

View File

View File

@ -0,0 +1,100 @@
variable "region" {
type = string
description = "Region this module and resources will be created in"
}
variable "name" {
type = string
description = "Unique name for the services in this module"
}
variable "environment" {
type = string
description = "Environment these resources reside (prod, dev, staging, etc)"
}
variable "owner" {
type = string
description = "Owner of the environment and resources created in this module"
}
variable "subnet_id" {
type = string
description = "Subnet the SQL server should run"
}
variable "sku_name" {
type = string
description = "SKU name"
default = "GP_Gen5_2"
}
variable "sku_capacity" {
type = string
description = "SKU Capacity"
default = "2"
}
variable "sku_tier" {
type = string
description = "SKU Tier"
default = "GeneralPurpose"
}
variable "sku_family" {
type = string
description = "SKU Family"
default = "Gen5"
}
variable "storage_mb" {
type = string
description = "Size in MB of the storage used for the sql server"
default = "5120"
}
variable "storage_backup_retention_days" {
type = string
description = "Storage backup retention (days)"
default = "7"
}
variable "storage_geo_redundant_backup" {
type = string
description = "Geographic redundant backup (Enabled/Disabled)"
default = "Disabled"
}
variable "storage_auto_grow" {
type = string
description = "Auto Grow? (Enabled/Disabled)"
default = "Enabled"
}
variable "administrator_login" {
type = string
description = "Administrator login"
default = "sqladmindude" # FIXME - Remove with wrapper using KeyVault
}
variable "administrator_login_password" {
type = string
description = "Administrator password"
default = "eI0l7yswwtuhHpwzoVjwRKdAcuGNsg" # FIXME - Remove with wrapper using KeyVault
}
variable "postgres_version" {
type = string
description = "Postgres version to use"
default = "11"
}
variable "ssl_enforcement" {
type = string
description = "Enforce SSL (Enabled/Disable)"
default = "Enabled"
}

View File

@ -0,0 +1,72 @@
resource "azurerm_resource_group" "vpc" {
name = "${var.name}-${var.environment}-vpc"
location = var.region
tags = {
environment = var.environment
owner = var.owner
}
}
resource "azurerm_network_ddos_protection_plan" "vpc" {
count = var.ddos_enabled
name = "${var.name}-${var.environment}-ddos"
location = azurerm_resource_group.vpc.location
resource_group_name = azurerm_resource_group.vpc.name
}
resource "azurerm_virtual_network" "vpc" {
name = "${var.name}-${var.environment}-network"
location = azurerm_resource_group.vpc.location
resource_group_name = azurerm_resource_group.vpc.name
address_space = ["${var.virtual_network}"]
dns_servers = var.dns_servers
tags = {
environment = var.environment
owner = var.owner
}
}
resource "azurerm_subnet" "subnet" {
for_each = var.networks
name = "${var.name}-${var.environment}-${each.key}"
resource_group_name = azurerm_resource_group.vpc.name
virtual_network_name = azurerm_virtual_network.vpc.name
address_prefix = element(split(",", each.value), 0)
# See https://github.com/terraform-providers/terraform-provider-azurerm/issues/3471
lifecycle {
ignore_changes = [route_table_id]
}
#delegation {
# name = "acctestdelegation"
#
# service_delegation {
# name = "Microsoft.ContainerInstance/containerGroups"
# actions = ["Microsoft.Network/virtualNetworks/subnets/action"]
# }
#}
}
resource "azurerm_route_table" "route_table" {
for_each = var.route_tables
name = "${var.name}-${var.environment}-${each.key}"
location = azurerm_resource_group.vpc.location
resource_group_name = azurerm_resource_group.vpc.name
}
resource "azurerm_subnet_route_table_association" "route_table" {
for_each = var.networks
subnet_id = azurerm_subnet.subnet[each.key].id
route_table_id = azurerm_route_table.route_table[each.key].id
}
resource "azurerm_route" "route" {
for_each = var.route_tables
name = "${var.name}-${var.environment}-default"
resource_group_name = azurerm_resource_group.vpc.name
route_table_name = azurerm_route_table.route_table[each.key].name
address_prefix = "0.0.0.0/0"
next_hop_type = each.value
}

View File

@ -0,0 +1,3 @@
output "subnets" {
value = azurerm_subnet.subnet["private"].id #FIXME - output should be a map
}

View File

@ -0,0 +1,43 @@
variable "environment" {
description = "Environment (Prod,Dev,etc)"
}
variable "region" {
description = "Region (useast2, etc)"
}
variable "name" {
description = "Name or prefix to use for all resources created by this module"
}
variable "owner" {
description = "Owner of these resources"
}
variable "ddos_enabled" {
description = "Enable or disable DDoS Protection (1,0)"
default = "0"
}
variable "virtual_network" {
description = "The supernet used for this VPC a.k.a Virtual Network"
type = string
}
variable "networks" {
description = "A map of lists describing the network topology"
type = map
}
variable "dns_servers" {
description = "DNS Server IPs for internal and public DNS lookups (must be on a defined subnet)"
type = list
}
variable "route_tables" {
type = map
description = "A map with the route tables to create"
}

Some files were not shown because too many files have changed in this diff Show More