Merge pull request #1243 from dod-ccpo/staging
Update master from staging
This commit is contained in:
commit
cc3863d926
@ -30,7 +30,7 @@ commands:
|
||||
default: atat_test
|
||||
container_env:
|
||||
type: string
|
||||
default: -e PGHOST=postgres -e REDIS_URI=redis://redis:6379
|
||||
default: -e PGHOST=postgres -e REDIS_HOST=redis:6379
|
||||
steps:
|
||||
- run:
|
||||
name: Set up temporary docker network
|
||||
@ -172,7 +172,7 @@ jobs:
|
||||
command: |
|
||||
docker run \
|
||||
-e PGHOST=postgres \
|
||||
-e REDIS_URI=redis://redis:6379 \
|
||||
-e REDIS_HOST=redis:6379 \
|
||||
--network atat \
|
||||
atat:builder \
|
||||
/bin/sh -c "pipenv install --dev && /bin/sh script/cibuild"
|
||||
@ -195,7 +195,7 @@ jobs:
|
||||
docker run -d \
|
||||
-e DISABLE_CRL_CHECK=true \
|
||||
-e PGHOST=postgres \
|
||||
-e REDIS_URI=redis://redis:6379 \
|
||||
-e REDIS_HOST=redis:6379 \
|
||||
-p 8000:8000 \
|
||||
--network atat \
|
||||
--name test-atat \
|
||||
@ -253,7 +253,7 @@ jobs:
|
||||
command: |
|
||||
docker run \
|
||||
-e PGHOST=postgres \
|
||||
-e REDIS_URI=redis://redis:6379 \
|
||||
-e REDIS_HOST=redis:6379 \
|
||||
--network atat \
|
||||
atat:builder \
|
||||
/bin/sh -c "pipenv install --dev && /bin/sh script/sync-crls && pipenv run pytest --no-cov tests/check_crl_parse.py"
|
||||
@ -293,6 +293,11 @@ workflows:
|
||||
- integration-tests:
|
||||
requires:
|
||||
- docker-build
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- staging
|
||||
- master
|
||||
- deploy-staging:
|
||||
requires:
|
||||
- test
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -33,6 +33,7 @@ static/buildinfo.*
|
||||
log/*
|
||||
|
||||
config/dev.ini
|
||||
.env*
|
||||
|
||||
# CRLs
|
||||
/crl
|
||||
|
@ -3,7 +3,7 @@
|
||||
"files": "^.secrets.baseline$|^.*pgsslrootcert.yml$",
|
||||
"lines": null
|
||||
},
|
||||
"generated_at": "2019-11-26T21:33:43Z",
|
||||
"generated_at": "2019-12-06T21:22:07Z",
|
||||
"plugins_used": [
|
||||
{
|
||||
"base64_limit": 4.5,
|
||||
@ -98,7 +98,7 @@
|
||||
"hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3",
|
||||
"is_secret": false,
|
||||
"is_verified": false,
|
||||
"line_number": 21,
|
||||
"line_number": 29,
|
||||
"type": "Secret Keyword"
|
||||
}
|
||||
],
|
||||
@ -161,7 +161,7 @@
|
||||
"hashed_secret": "e4f14805dfd1e6af030359090c535e149e6b4207",
|
||||
"is_secret": false,
|
||||
"is_verified": false,
|
||||
"line_number": 32,
|
||||
"line_number": 41,
|
||||
"type": "Hex High Entropy String"
|
||||
}
|
||||
],
|
||||
|
@ -87,6 +87,7 @@ COPY --from=builder /install/translations.yaml .
|
||||
COPY --from=builder /install/script/seed_roles.py ./script/seed_roles.py
|
||||
COPY --from=builder /install/script/sync-crls ./script/sync-crls
|
||||
COPY --from=builder /install/static/ ./static/
|
||||
COPY --from=builder /install/fixtures/ ./fixtures
|
||||
COPY --from=builder /install/uwsgi.ini .
|
||||
COPY --from=builder /usr/local/bin/uwsgi /usr/local/bin/uwsgi
|
||||
|
||||
|
1
Pipfile
1
Pipfile
@ -29,6 +29,7 @@ azure-mgmt-subscription = "*"
|
||||
azure-graphrbac = "*"
|
||||
msrestazure = "*"
|
||||
azure-mgmt-authorization = "*"
|
||||
azure-mgmt-managementgroups = "*"
|
||||
|
||||
[dev-packages]
|
||||
bandit = "*"
|
||||
|
177
Pipfile.lock
generated
177
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "6d2ab855267daac877ae7464de9dba5b62b7d89288992f87d8fc6ff0c0d2520f"
|
||||
"sha256": "c2b19c436646705ea3bf4df8c35c2833083f048da37fc619e66f7236153607c5"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@ -39,11 +39,11 @@
|
||||
},
|
||||
"apache-libcloud": {
|
||||
"hashes": [
|
||||
"sha256:201751f738109f25d58dcdfb5804e17216e0dc8f68b522e9e26ac16e0b9ff2ea",
|
||||
"sha256:40215db1bd489d17dc1abfdb289d7f035313c7297b6a7462c79d8287cbbeae91"
|
||||
"sha256:9bc5cd5c32151bb7a04a7c7de0be9b4a4b8271e348ac91dd79eaaeeae627115f",
|
||||
"sha256:fcc165f2cc2db9a379c6d3a17b3beb9081bb64ba5c0bf7bbb58da864810092f0"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.6.0"
|
||||
"version": "==2.6.1"
|
||||
},
|
||||
"azure-common": {
|
||||
"hashes": [
|
||||
@ -68,6 +68,14 @@
|
||||
"index": "pypi",
|
||||
"version": "==0.60.0"
|
||||
},
|
||||
"azure-mgmt-managementgroups": {
|
||||
"hashes": [
|
||||
"sha256:3d5237947458dc94b4a392141174b1c1258d26611241ee104e9006d1d798f682",
|
||||
"sha256:8194ee6274df865eccd1ed9d385ea625aeba9b8058b9e4fdf547f5207271a775"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.2.0"
|
||||
},
|
||||
"azure-mgmt-subscription": {
|
||||
"hashes": [
|
||||
"sha256:504b4c42ba859070c3c50637ec07ca36aca600e613fcccaa398db22822fe21f1",
|
||||
@ -117,10 +125,10 @@
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
|
||||
"sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
|
||||
"sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
|
||||
"sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
|
||||
],
|
||||
"version": "==2019.9.11"
|
||||
"version": "==2019.11.28"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
@ -248,10 +256,10 @@
|
||||
},
|
||||
"importlib-metadata": {
|
||||
"hashes": [
|
||||
"sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
|
||||
"sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
|
||||
"sha256:b044f07694ef14a6683b097ba56bd081dbc7cdc7c7fe46011e499dfecc082f21",
|
||||
"sha256:e6ac600a142cf2db707b1998382cc7fc3b02befb7273876e01b8ad10b9652742"
|
||||
],
|
||||
"version": "==0.23"
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"isodate": {
|
||||
"hashes": [
|
||||
@ -330,10 +338,10 @@
|
||||
},
|
||||
"more-itertools": {
|
||||
"hashes": [
|
||||
"sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
|
||||
"sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
|
||||
"sha256:53ff73f186307d9c8ef17a9600309154a6ae27f25579e80af4db8f047ba14bc2",
|
||||
"sha256:a0ea684c39bc4315ba7aae406596ef191fd84f873d2d2751f84d64e81a7a2d45"
|
||||
],
|
||||
"version": "==7.2.0"
|
||||
"version": "==8.0.0"
|
||||
},
|
||||
"msrest": {
|
||||
"hashes": [
|
||||
@ -422,11 +430,11 @@
|
||||
},
|
||||
"pyopenssl": {
|
||||
"hashes": [
|
||||
"sha256:aeca66338f6de19d1aa46ed634c3b9ae519a64b458f8468aec688e7e3c20f200",
|
||||
"sha256:c727930ad54b10fc157015014b666f2d8b41f70c0d03e83ab67624fd3dd5d1e6"
|
||||
"sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504",
|
||||
"sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==19.0.0"
|
||||
"version": "==19.1.0"
|
||||
},
|
||||
"python-dateutil": {
|
||||
"hashes": [
|
||||
@ -459,22 +467,20 @@
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
|
||||
"sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4",
|
||||
"sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8",
|
||||
"sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696",
|
||||
"sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34",
|
||||
"sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9",
|
||||
"sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73",
|
||||
"sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299",
|
||||
"sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b",
|
||||
"sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae",
|
||||
"sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681",
|
||||
"sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
|
||||
"sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
|
||||
"sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
|
||||
"sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803",
|
||||
"sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc",
|
||||
"sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15",
|
||||
"sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075",
|
||||
"sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd",
|
||||
"sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31",
|
||||
"sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f",
|
||||
"sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c",
|
||||
"sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04",
|
||||
"sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==5.1.2"
|
||||
"version": "==5.2"
|
||||
},
|
||||
"redis": {
|
||||
"hashes": [
|
||||
@ -650,10 +656,10 @@
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
|
||||
"sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
|
||||
"sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
|
||||
"sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
|
||||
],
|
||||
"version": "==2019.9.11"
|
||||
"version": "==2019.11.28"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
@ -785,10 +791,10 @@
|
||||
},
|
||||
"importlib-metadata": {
|
||||
"hashes": [
|
||||
"sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
|
||||
"sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
|
||||
"sha256:b044f07694ef14a6683b097ba56bd081dbc7cdc7c7fe46011e499dfecc082f21",
|
||||
"sha256:e6ac600a142cf2db707b1998382cc7fc3b02befb7273876e01b8ad10b9652742"
|
||||
],
|
||||
"version": "==0.23"
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"ipdb": {
|
||||
"hashes": [
|
||||
@ -799,11 +805,11 @@
|
||||
},
|
||||
"ipython": {
|
||||
"hashes": [
|
||||
"sha256:dfd303b270b7b5232b3d08bd30ec6fd685d8a58cabd54055e3d69d8f029f7280",
|
||||
"sha256:ed7ebe1cba899c1c3ccad6f7f1c2d2369464cc77dba8eebc65e2043e19cda995"
|
||||
"sha256:c66c7e27239855828a764b1e8fc72c24a6f4498a2637572094a78c5551fb9d51",
|
||||
"sha256:f186b01b36609e0c5d0de27c7ef8e80c990c70478f8c880863004b3489a9030e"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==7.9.0"
|
||||
"version": "==7.10.1"
|
||||
},
|
||||
"ipython-genutils": {
|
||||
"hashes": [
|
||||
@ -908,30 +914,30 @@
|
||||
},
|
||||
"more-itertools": {
|
||||
"hashes": [
|
||||
"sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
|
||||
"sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
|
||||
"sha256:53ff73f186307d9c8ef17a9600309154a6ae27f25579e80af4db8f047ba14bc2",
|
||||
"sha256:a0ea684c39bc4315ba7aae406596ef191fd84f873d2d2751f84d64e81a7a2d45"
|
||||
],
|
||||
"version": "==7.2.0"
|
||||
"version": "==8.0.0"
|
||||
},
|
||||
"mypy": {
|
||||
"hashes": [
|
||||
"sha256:1521c186a3d200c399bd5573c828ea2db1362af7209b2adb1bb8532cea2fb36f",
|
||||
"sha256:31a046ab040a84a0fc38bc93694876398e62bc9f35eca8ccbf6418b7297f4c00",
|
||||
"sha256:3b1a411909c84b2ae9b8283b58b48541654b918e8513c20a400bb946aa9111ae",
|
||||
"sha256:48c8bc99380575deb39f5d3400ebb6a8a1cb5cc669bbba4d3bb30f904e0a0e7d",
|
||||
"sha256:540c9caa57a22d0d5d3c69047cc9dd0094d49782603eb03069821b41f9e970e9",
|
||||
"sha256:672e418425d957e276c291930a3921b4a6413204f53fe7c37cad7bc57b9a3391",
|
||||
"sha256:6ed3b9b3fdc7193ea7aca6f3c20549b377a56f28769783a8f27191903a54170f",
|
||||
"sha256:9371290aa2cad5ad133e4cdc43892778efd13293406f7340b9ffe99d5ec7c1d9",
|
||||
"sha256:ace6ac1d0f87d4072f05b5468a084a45b4eda970e4d26704f201e06d47ab2990",
|
||||
"sha256:b428f883d2b3fe1d052c630642cc6afddd07d5cd7873da948644508be3b9d4a7",
|
||||
"sha256:d5bf0e6ec8ba346a2cf35cb55bf4adfddbc6b6576fcc9e10863daa523e418dbb",
|
||||
"sha256:d7574e283f83c08501607586b3167728c58e8442947e027d2d4c7dcd6d82f453",
|
||||
"sha256:dc889c84241a857c263a2b1cd1121507db7d5b5f5e87e77147097230f374d10b",
|
||||
"sha256:f4748697b349f373002656bf32fede706a0e713d67bfdcf04edf39b1f61d46eb"
|
||||
"sha256:02d9bdd3398b636723ecb6c5cfe9773025a9ab7f34612c1cde5c7f2292e2d768",
|
||||
"sha256:088f758a50af31cf8b42688118077292370c90c89232c783ba7979f39ea16646",
|
||||
"sha256:28e9fbc96d13397a7ddb7fad7b14f373f91b5cff538e0772e77c270468df083c",
|
||||
"sha256:30e123b24931f02c5d99307406658ac8f9cd6746f0d45a3dcac2fe5fbdd60939",
|
||||
"sha256:3294821b5840d51a3cd7a2bb63b40fc3f901f6a3cfb3c6046570749c4c7ef279",
|
||||
"sha256:41696a7d912ce16fdc7c141d87e8db5144d4be664a0c699a2b417d393994b0c2",
|
||||
"sha256:4f42675fa278f3913340bb8c3371d191319704437758d7c4a8440346c293ecb2",
|
||||
"sha256:54d205ccce6ed930a8a2ccf48404896d456e8b87812e491cb907a355b1a9c640",
|
||||
"sha256:6992133c95a2847d309b4b0c899d7054adc60481df6f6b52bb7dee3d5fd157f7",
|
||||
"sha256:6ecbd0e8e371333027abca0922b0c2c632a5b4739a0c61ffbd0733391e39144c",
|
||||
"sha256:83fa87f556e60782c0fc3df1b37b7b4a840314ba1ac27f3e1a1e10cb37c89c17",
|
||||
"sha256:c87ac7233c629f305602f563db07f5221950fe34fe30af072ac838fa85395f78",
|
||||
"sha256:de9ec8dba773b78c49e7bec9a35c9b6fc5235682ad1fc2105752ae7c22f4b931",
|
||||
"sha256:f385a0accf353ca1bca4bbf473b9d83ed18d923fdb809d3a70a385da23e25b6a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.740"
|
||||
"version": "==0.750"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
@ -961,10 +967,10 @@
|
||||
},
|
||||
"pbr": {
|
||||
"hashes": [
|
||||
"sha256:2c8e420cd4ed4cec4e7999ee47409e876af575d4c35a45840d59e8b5f3155ab8",
|
||||
"sha256:b32c8ccaac7b1a20c0ce00ce317642e6cf231cf038f9875e0280e28af5bf7ac9"
|
||||
"sha256:139d2625547dbfa5fb0b81daebb39601c478c21956dc57e2e07b74450a8c506b",
|
||||
"sha256:61aa52a0f18b71c5cc58232d2cf8f8d09cd67fcad60b742a60124cb8d6951488"
|
||||
],
|
||||
"version": "==5.4.3"
|
||||
"version": "==5.4.4"
|
||||
},
|
||||
"pexpect": {
|
||||
"hashes": [
|
||||
@ -983,18 +989,17 @@
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6",
|
||||
"sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"
|
||||
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
|
||||
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
|
||||
],
|
||||
"version": "==0.13.0"
|
||||
"version": "==0.13.1"
|
||||
},
|
||||
"prompt-toolkit": {
|
||||
"hashes": [
|
||||
"sha256:46642344ce457641f28fc9d1c9ca939b63dadf8df128b86f1b9860e59c73a5e4",
|
||||
"sha256:e7f8af9e3d70f514373bf41aa51bc33af12a6db3f71461ea47fea985defb2c31",
|
||||
"sha256:f15af68f66e664eaa559d4ac8a928111eebd5feda0c11738b5998045224829db"
|
||||
"sha256:0278d2f51b5ceba6ea8da39f76d15684e84c996b325475f6e5720edc584326a7",
|
||||
"sha256:63daee79aa8366c8f1c637f1a4876b890da5fc92a19ebd2f7080ebacb901e990"
|
||||
],
|
||||
"version": "==2.0.10"
|
||||
"version": "==3.0.2"
|
||||
},
|
||||
"ptyprocess": {
|
||||
"hashes": [
|
||||
@ -1012,10 +1017,10 @@
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127",
|
||||
"sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"
|
||||
"sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
|
||||
"sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"
|
||||
],
|
||||
"version": "==2.4.2"
|
||||
"version": "==2.5.2"
|
||||
},
|
||||
"pylint": {
|
||||
"hashes": [
|
||||
@ -1058,11 +1063,11 @@
|
||||
},
|
||||
"pytest-mock": {
|
||||
"hashes": [
|
||||
"sha256:b3514caac35fe3f05555923eabd9546abce11571cc2ddf7d8615959d04f2c89e",
|
||||
"sha256:ea502c3891599c26243a3a847ccf0b1d20556678c528f86c98e3cd6d40c5cf11"
|
||||
"sha256:96a0cebc66e09930be2a15b03333d90b59584d3fb011924f81c14b50ee0afbba",
|
||||
"sha256:e5381be2608e49547f5e47633c5f81241ebf6206d17ce516a7a18d5a917e3859"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.11.2"
|
||||
"version": "==1.12.1"
|
||||
},
|
||||
"pytest-watch": {
|
||||
"hashes": [
|
||||
@ -1080,22 +1085,20 @@
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
|
||||
"sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4",
|
||||
"sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8",
|
||||
"sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696",
|
||||
"sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34",
|
||||
"sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9",
|
||||
"sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73",
|
||||
"sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299",
|
||||
"sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b",
|
||||
"sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae",
|
||||
"sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681",
|
||||
"sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
|
||||
"sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
|
||||
"sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc",
|
||||
"sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803",
|
||||
"sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc",
|
||||
"sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15",
|
||||
"sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075",
|
||||
"sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd",
|
||||
"sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31",
|
||||
"sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f",
|
||||
"sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c",
|
||||
"sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04",
|
||||
"sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==5.1.2"
|
||||
"version": "==5.2"
|
||||
},
|
||||
"regex": {
|
||||
"hashes": [
|
||||
|
46
README.md
46
README.md
@ -168,12 +168,7 @@ Testing file uploads and downloads locally requires a few configuration options.
|
||||
In the flask config (`config/base.ini`, perhaps):
|
||||
|
||||
```
|
||||
CSP=<aws | azure | mock>
|
||||
|
||||
AWS_REGION_NAME=""
|
||||
AWS_ACCESS_KEY=""
|
||||
AWS_SECRET_KEY=""
|
||||
AWS_BUCKET_NAME=""
|
||||
CSP=< azure | mock>
|
||||
|
||||
AZURE_STORAGE_KEY=""
|
||||
AZURE_ACCOUNT_NAME=""
|
||||
@ -183,7 +178,7 @@ AZURE_TO_BUCKET_NAME=""
|
||||
There are also some build-time configuration that are used by parcel. Add these to `.env.local`, and run `rm -r .cache/` before running `yarn build`:
|
||||
|
||||
```
|
||||
CLOUD_PROVIDER=<aws | azure | mock>
|
||||
CLOUD_PROVIDER=<azure | mock>
|
||||
AZURE_ACCOUNT_NAME=""
|
||||
AZURE_CONTAINER_NAME=""
|
||||
```
|
||||
@ -223,6 +218,9 @@ To generate coverage reports for the Javascript tests:
|
||||
## Configuration
|
||||
|
||||
- `ASSETS_URL`: URL to host which serves static assets (such as a CDN).
|
||||
- `AZURE_ACCOUNT_NAME`: The name for the Azure blob storage account
|
||||
- `AZURE_STORAGE_KEY`: A valid secret key for the Azure blob storage account
|
||||
- `AZURE_TO_BUCKET_NAME`: The Azure blob storage container name for task order uploads
|
||||
- `BLOB_STORAGE_URL`: URL to Azure blob storage container.
|
||||
- `CAC_URL`: URL for the CAC authentication route.
|
||||
- `CA_CHAIN`: Path to the CA chain file.
|
||||
@ -238,6 +236,11 @@ To generate coverage reports for the Javascript tests:
|
||||
- `ENVIRONMENT`: String specifying the current environment. Acceptable values: "dev", "prod".
|
||||
- `LIMIT_CONCURRENT_SESSIONS`: Boolean specifying if users should be allowed only one active session at a time.
|
||||
- `LOG_JSON`: Boolean specifying whether app should log in a json format.
|
||||
- `MAIL_PASSWORD`: String. Password for the SMTP server.
|
||||
- `MAIL_PORT`: Integer. Port to use on the SMTP server.
|
||||
- `MAIL_SENDER`: String. Email address to send outgoing mail from.
|
||||
- `MAIL_SERVER`: The SMTP host
|
||||
- `MAIL_TLS`: Boolean. Use TLS to connect to the SMTP server.
|
||||
- `PERMANENT_SESSION_LIFETIME`: Integer specifying how many seconds a user's session can stay valid for. https://flask.palletsprojects.com/en/1.1.x/config/#PERMANENT_SESSION_LIFETIME
|
||||
- `PGDATABASE`: String specifying the name of the postgres database.
|
||||
- `PGHOST`: String specifying the hostname of the postgres database.
|
||||
@ -270,33 +273,8 @@ execute UI tests than vanilla Selenium. Ghost Inspector tests and steps can
|
||||
be exported to files that the Selenium IDE can import. We export these tests/steps
|
||||
regularly and archive them with the AT-AT codebase in the `uitests` directory.
|
||||
|
||||
To run the Ghost Inspector tests against a local instance of AT-AT,
|
||||
you will need the following:
|
||||
|
||||
- [docker](https://docs.docker.com/v17.12/install/)
|
||||
- [circleci CLI tool](https://circleci.com/docs/2.0/local-cli/#installation)
|
||||
- the prerequisite variable information listed [here](https://ghostinspector.com/docs/integration/circle-ci/)
|
||||
|
||||
The version of our CircleCI config (2.1) is incompatible with the
|
||||
`circleci` tool. First run:
|
||||
|
||||
```
|
||||
circleci config process .circleci/config.yml > local-ci.yml
|
||||
```
|
||||
|
||||
Then run the job:
|
||||
|
||||
```
|
||||
circleci local execute -e GI_SUITE=<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
|
||||
```
|
||||
For further information about Ghost Inspector and its use in AT-AT, check out [its README](./uitests/README.md)
|
||||
in the `uitests` directory.
|
||||
|
||||
## Notes
|
||||
|
||||
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
68
atst/app.py
68
atst/app.py
@ -200,46 +200,78 @@ def make_config(direct_config=None):
|
||||
ENV_CONFIG_FILENAME = os.path.join(
|
||||
os.path.dirname(__file__), "../config/", "{}.ini".format(ENV.lower())
|
||||
)
|
||||
OVERRIDE_CONFIG_FILENAME = os.getenv("OVERRIDE_CONFIG_FULLPATH")
|
||||
OVERRIDE_CONFIG_DIRECTORY = os.getenv("OVERRIDE_CONFIG_DIRECTORY")
|
||||
|
||||
config = ConfigParser(allow_no_value=True)
|
||||
config.optionxform = str
|
||||
|
||||
config_files = [BASE_CONFIG_FILENAME, ENV_CONFIG_FILENAME]
|
||||
if OVERRIDE_CONFIG_FILENAME:
|
||||
config_files.append(OVERRIDE_CONFIG_FILENAME)
|
||||
|
||||
# ENV_CONFIG will override values in BASE_CONFIG.
|
||||
config.read(config_files)
|
||||
|
||||
if OVERRIDE_CONFIG_DIRECTORY:
|
||||
apply_config_from_directory(OVERRIDE_CONFIG_DIRECTORY, config)
|
||||
|
||||
# Check for ENV variables as a final source of overrides
|
||||
for confsetting in config.options("default"):
|
||||
env_override = os.getenv(confsetting.upper())
|
||||
if env_override:
|
||||
config.set("default", confsetting, env_override)
|
||||
apply_config_from_environment(config)
|
||||
|
||||
# override if a dictionary of options has been given
|
||||
if direct_config:
|
||||
config.read_dict({"default": direct_config})
|
||||
|
||||
# Assemble DATABASE_URI value
|
||||
database_uri = (
|
||||
"postgres://"
|
||||
+ config.get("default", "PGUSER")
|
||||
+ ":"
|
||||
+ config.get("default", "PGPASSWORD")
|
||||
+ "@"
|
||||
+ config.get("default", "PGHOST")
|
||||
+ ":"
|
||||
+ config.get("default", "PGPORT")
|
||||
+ "/"
|
||||
+ config.get("default", "PGDATABASE")
|
||||
database_uri = "postgres://{}:{}@{}:{}/{}".format( # pragma: allowlist secret
|
||||
config.get("default", "PGUSER"),
|
||||
config.get("default", "PGPASSWORD"),
|
||||
config.get("default", "PGHOST"),
|
||||
config.get("default", "PGPORT"),
|
||||
config.get("default", "PGDATABASE"),
|
||||
)
|
||||
config.set("default", "DATABASE_URI", database_uri)
|
||||
|
||||
# Assemble REDIS_URI value
|
||||
redis_uri = "redis{}://{}:{}@{}".format( # pragma: allowlist secret
|
||||
("s" if config["default"].getboolean("REDIS_TLS") else ""),
|
||||
(config.get("default", "REDIS_USER") or ""),
|
||||
(config.get("default", "REDIS_PASSWORD") or ""),
|
||||
config.get("default", "REDIS_HOST"),
|
||||
)
|
||||
config.set("default", "REDIS_URI", redis_uri)
|
||||
|
||||
return map_config(config)
|
||||
|
||||
|
||||
def apply_config_from_directory(config_dir, config, section="default"):
|
||||
"""
|
||||
Loop files in a directory, check if the names correspond to
|
||||
known config values, and apply the file contents as the value
|
||||
for that setting if they do.
|
||||
"""
|
||||
for confsetting in os.listdir(config_dir):
|
||||
if confsetting in config.options(section):
|
||||
full_path = os.path.join(config_dir, confsetting)
|
||||
with open(full_path, "r") as conf_file:
|
||||
config.set(section, confsetting, conf_file.read().strip())
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def apply_config_from_environment(config, section="default"):
|
||||
"""
|
||||
Loops all the configuration settins in a given section of a
|
||||
config object and checks whether those settings also exist as
|
||||
environment variables. If so, it applies the environment
|
||||
variables value as the new configuration setting value.
|
||||
"""
|
||||
for confsetting in config.options(section):
|
||||
env_override = os.getenv(confsetting.upper())
|
||||
if env_override:
|
||||
config.set(section, confsetting, env_override)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def make_redis(app, config):
|
||||
r = redis.Redis.from_url(config["REDIS_URI"])
|
||||
app.redis = r
|
||||
|
@ -3,6 +3,7 @@ import re
|
||||
from uuid import uuid4
|
||||
|
||||
from atst.models.user import User
|
||||
from atst.models.application import Application
|
||||
from atst.models.environment import Environment
|
||||
from atst.models.environment_role import EnvironmentRole
|
||||
|
||||
@ -399,13 +400,14 @@ REMOTE_ROOT_ROLE_DEF_ID = "/providers/Microsoft.Authorization/roleDefinitions/00
|
||||
|
||||
class AzureSDKProvider(object):
|
||||
def __init__(self):
|
||||
from azure.mgmt import subscription, authorization
|
||||
from azure.mgmt import subscription, authorization, managementgroups
|
||||
import azure.graphrbac as graphrbac
|
||||
import azure.common.credentials as credentials
|
||||
from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD
|
||||
|
||||
self.subscription = subscription
|
||||
self.authorization = authorization
|
||||
self.managementgroups = managementgroups
|
||||
self.graphrbac = graphrbac
|
||||
self.credentials = credentials
|
||||
# may change to a JEDI cloud
|
||||
@ -428,42 +430,23 @@ class AzureCloudProvider(CloudProviderInterface):
|
||||
def create_environment(
|
||||
self, auth_credentials: Dict, user: User, environment: Environment
|
||||
):
|
||||
# since this operation would only occur within a tenant, should we source the tenant
|
||||
# via lookup from environment once we've created the portfolio csp data schema
|
||||
# something like this:
|
||||
# environment_tenant = environment.application.portfolio.csp_data.get('tenant_id', None)
|
||||
# though we'd probably source the whole credentials for these calls from the portfolio csp
|
||||
# data, as it would have to be where we store the creds for the at-at user within the portfolio tenant
|
||||
# credentials = self._get_credential_obj(environment.application.portfolio.csp_data.get_creds())
|
||||
credentials = self._get_credential_obj(self._root_creds)
|
||||
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
|
||||
|
||||
display_name = f"{environment.application.name}_{environment.name}_{environment.id}" # proposed format
|
||||
management_group_id = "?" # management group id chained from environment
|
||||
parent_id = "?" # from environment.application
|
||||
|
||||
billing_profile_id = "?" # something chained from environment?
|
||||
sku_id = AZURE_SKU_ID
|
||||
# we want to set AT-AT as an owner here
|
||||
# we could potentially associate subscriptions with "management groups" per DOD component
|
||||
body = self.sdk.subscription.models.ModernSubscriptionCreationParameters(
|
||||
display_name,
|
||||
billing_profile_id,
|
||||
sku_id,
|
||||
# owner=<AdPrincipal: for AT-AT user>
|
||||
management_group = self._create_management_group(
|
||||
credentials, management_group_id, display_name, parent_id,
|
||||
)
|
||||
|
||||
# These 2 seem like something that might be worthwhile to allow tiebacks to
|
||||
# TOs filed for the environment
|
||||
billing_account_name = "?"
|
||||
invoice_section_name = "?"
|
||||
# We may also want to create billing sections in the enrollment account
|
||||
sub_creation_operation = sub_client.subscription_factory.create_subscription(
|
||||
billing_account_name, invoice_section_name, body
|
||||
)
|
||||
|
||||
# the resulting object from this process is a link to the new subscription
|
||||
# not a subscription model, so we'll have to unpack the ID
|
||||
new_sub = sub_creation_operation.result()
|
||||
|
||||
subscription_id = self._extract_subscription_id(new_sub.subscription_link)
|
||||
if subscription_id:
|
||||
return subscription_id
|
||||
else:
|
||||
# troublesome error, subscription should exist at this point
|
||||
# but we just don't have a valid ID
|
||||
pass
|
||||
return management_group
|
||||
|
||||
def create_atat_admin_user(
|
||||
self, auth_credentials: Dict, csp_environment_id: str
|
||||
@ -502,6 +485,82 @@ class AzureCloudProvider(CloudProviderInterface):
|
||||
"role_name": role_assignment_id,
|
||||
}
|
||||
|
||||
def _create_application(self, auth_credentials: Dict, application: Application):
|
||||
management_group_name = str(uuid4()) # can be anything, not just uuid
|
||||
display_name = application.name # Does this need to be unique?
|
||||
credentials = self._get_credential_obj(auth_credentials)
|
||||
parent_id = "?" # application.portfolio.csp_details.management_group_id
|
||||
|
||||
return self._create_management_group(
|
||||
credentials, management_group_name, display_name, parent_id,
|
||||
)
|
||||
|
||||
def _create_management_group(
|
||||
self, credentials, management_group_id, display_name, parent_id=None,
|
||||
):
|
||||
mgmgt_group_client = self.sdk.managementgroups.ManagementGroupsAPI(credentials)
|
||||
create_parent_grp_info = self.sdk.managementgroups.models.CreateParentGroupInfo(
|
||||
id=parent_id
|
||||
)
|
||||
create_mgmt_grp_details = self.sdk.managementgroups.models.CreateManagementGroupDetails(
|
||||
parent=create_parent_grp_info
|
||||
)
|
||||
mgmt_grp_create = self.sdk.managementgroups.models.CreateManagementGroupRequest(
|
||||
name=management_group_id,
|
||||
display_name=display_name,
|
||||
details=create_mgmt_grp_details,
|
||||
)
|
||||
create_request = mgmgt_group_client.management_groups.create_or_update(
|
||||
management_group_id, mgmt_grp_create
|
||||
)
|
||||
|
||||
# result is a synchronous wait, might need to do a poll instead to handle first mgmt group create
|
||||
# since we were told it could take 10+ minutes to complete, unless this handles that polling internally
|
||||
return create_request.result()
|
||||
|
||||
def _create_subscription(
|
||||
self,
|
||||
credentials,
|
||||
display_name,
|
||||
billing_profile_id,
|
||||
sku_id,
|
||||
management_group_id,
|
||||
billing_account_name,
|
||||
invoice_section_name,
|
||||
):
|
||||
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
|
||||
|
||||
billing_profile_id = "?" # where do we source this?
|
||||
sku_id = AZURE_SKU_ID
|
||||
# These 2 seem like something that might be worthwhile to allow tiebacks to
|
||||
# TOs filed for the environment
|
||||
billing_account_name = "?" # from TO?
|
||||
invoice_section_name = "?" # from TO?
|
||||
|
||||
body = self.sdk.subscription.models.ModernSubscriptionCreationParameters(
|
||||
display_name=display_name,
|
||||
billing_profile_id=billing_profile_id,
|
||||
sku_id=sku_id,
|
||||
management_group_id=management_group_id,
|
||||
)
|
||||
|
||||
# We may also want to create billing sections in the enrollment account
|
||||
sub_creation_operation = sub_client.subscription_factory.create_subscription(
|
||||
billing_account_name, invoice_section_name, body
|
||||
)
|
||||
|
||||
# the resulting object from this process is a link to the new subscription
|
||||
# not a subscription model, so we'll have to unpack the ID
|
||||
new_sub = sub_creation_operation.result()
|
||||
|
||||
subscription_id = self._extract_subscription_id(new_sub.subscription_link)
|
||||
if subscription_id:
|
||||
return subscription_id
|
||||
else:
|
||||
# troublesome error, subscription should exist at this point
|
||||
# but we just don't have a valid ID
|
||||
pass
|
||||
|
||||
def _get_management_service_principal(self):
|
||||
# we really should be using graph.microsoft.com, but i'm getting
|
||||
# "expired token" errors for that
|
||||
|
@ -1,327 +1,121 @@
|
||||
from itertools import groupby
|
||||
import pendulum
|
||||
from collections import defaultdict
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
class ReportingInterface:
|
||||
def monthly_totals_for_environment(environment):
|
||||
"""Return the monthly totals for the specified environment.
|
||||
def load_fixture_data():
|
||||
with open("fixtures/fixture_spend_data.json") as json_file:
|
||||
return json.load(json_file)
|
||||
|
||||
Data should be in the format of a dictionary with the month as the key
|
||||
and the spend in that month as the value. For example:
|
||||
|
||||
{ "01/2018": 79.85, "02/2018": 86.54 }
|
||||
class MockReportingProvider:
|
||||
FIXTURE_SPEND_DATA = load_fixture_data()
|
||||
|
||||
@classmethod
|
||||
def get_portfolio_monthly_spending(cls, portfolio):
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class MockEnvironment:
|
||||
def __init__(self, id_, env_name):
|
||||
self.id = id_
|
||||
self.name = env_name
|
||||
|
||||
|
||||
class MockApplication:
|
||||
def __init__(self, application_name, envs):
|
||||
def make_env(name):
|
||||
return MockEnvironment("{}_{}".format(application_name, name), name)
|
||||
|
||||
self.name = application_name
|
||||
self.environments = [make_env(env_name) for env_name in envs]
|
||||
|
||||
|
||||
def generate_sample_dates(_max=8):
|
||||
current = pendulum.now()
|
||||
sample_dates = []
|
||||
for _i in range(_max):
|
||||
sample_dates.append(current.strftime("%m/%Y"))
|
||||
current = current.subtract(months=1)
|
||||
|
||||
reversed(sample_dates)
|
||||
return sample_dates
|
||||
|
||||
|
||||
class MockReportingProvider(ReportingInterface):
|
||||
MOCK_PERCENT_EXPENDED_FUNDS = 0.75
|
||||
|
||||
FIXTURE_MONTHS = generate_sample_dates()
|
||||
|
||||
MONTHLY_SPEND_BY_ENVIRONMENT = {
|
||||
"LC04_Integ": {
|
||||
FIXTURE_MONTHS[7]: 284,
|
||||
FIXTURE_MONTHS[6]: 1210,
|
||||
FIXTURE_MONTHS[5]: 1430,
|
||||
FIXTURE_MONTHS[4]: 1366,
|
||||
FIXTURE_MONTHS[3]: 1169,
|
||||
FIXTURE_MONTHS[2]: 991,
|
||||
FIXTURE_MONTHS[1]: 978,
|
||||
FIXTURE_MONTHS[0]: 737,
|
||||
},
|
||||
"LC04_PreProd": {
|
||||
FIXTURE_MONTHS[7]: 812,
|
||||
FIXTURE_MONTHS[6]: 1389,
|
||||
FIXTURE_MONTHS[5]: 1425,
|
||||
FIXTURE_MONTHS[4]: 1306,
|
||||
FIXTURE_MONTHS[3]: 1112,
|
||||
FIXTURE_MONTHS[2]: 936,
|
||||
FIXTURE_MONTHS[1]: 921,
|
||||
FIXTURE_MONTHS[0]: 694,
|
||||
},
|
||||
"LC04_Prod": {
|
||||
FIXTURE_MONTHS[7]: 1742,
|
||||
FIXTURE_MONTHS[6]: 1716,
|
||||
FIXTURE_MONTHS[5]: 1866,
|
||||
FIXTURE_MONTHS[4]: 1809,
|
||||
FIXTURE_MONTHS[3]: 1839,
|
||||
FIXTURE_MONTHS[2]: 1633,
|
||||
FIXTURE_MONTHS[1]: 1654,
|
||||
FIXTURE_MONTHS[0]: 1103,
|
||||
},
|
||||
"SF18_Integ": {
|
||||
FIXTURE_MONTHS[5]: 1498,
|
||||
FIXTURE_MONTHS[4]: 1400,
|
||||
FIXTURE_MONTHS[3]: 1394,
|
||||
FIXTURE_MONTHS[2]: 1171,
|
||||
FIXTURE_MONTHS[1]: 1200,
|
||||
FIXTURE_MONTHS[0]: 963,
|
||||
},
|
||||
"SF18_PreProd": {
|
||||
FIXTURE_MONTHS[5]: 1780,
|
||||
FIXTURE_MONTHS[4]: 1667,
|
||||
FIXTURE_MONTHS[3]: 1703,
|
||||
FIXTURE_MONTHS[2]: 1474,
|
||||
FIXTURE_MONTHS[1]: 1441,
|
||||
FIXTURE_MONTHS[0]: 933,
|
||||
},
|
||||
"SF18_Prod": {
|
||||
FIXTURE_MONTHS[5]: 1686,
|
||||
FIXTURE_MONTHS[4]: 1779,
|
||||
FIXTURE_MONTHS[3]: 1792,
|
||||
FIXTURE_MONTHS[2]: 1570,
|
||||
FIXTURE_MONTHS[1]: 1539,
|
||||
FIXTURE_MONTHS[0]: 986,
|
||||
},
|
||||
"Canton_Prod": {
|
||||
FIXTURE_MONTHS[4]: 28699,
|
||||
FIXTURE_MONTHS[3]: 26766,
|
||||
FIXTURE_MONTHS[2]: 22619,
|
||||
FIXTURE_MONTHS[1]: 24090,
|
||||
FIXTURE_MONTHS[0]: 16719,
|
||||
},
|
||||
"BD04_Integ": {},
|
||||
"BD04_PreProd": {
|
||||
FIXTURE_MONTHS[7]: 7019,
|
||||
FIXTURE_MONTHS[6]: 3004,
|
||||
FIXTURE_MONTHS[5]: 2691,
|
||||
FIXTURE_MONTHS[4]: 2901,
|
||||
FIXTURE_MONTHS[3]: 3463,
|
||||
FIXTURE_MONTHS[2]: 3314,
|
||||
FIXTURE_MONTHS[1]: 3432,
|
||||
FIXTURE_MONTHS[0]: 723,
|
||||
},
|
||||
"SCV18_Dev": {FIXTURE_MONTHS[1]: 9797},
|
||||
"Crown_CR Portal Dev": {
|
||||
FIXTURE_MONTHS[6]: 208,
|
||||
FIXTURE_MONTHS[5]: 457,
|
||||
FIXTURE_MONTHS[4]: 671,
|
||||
FIXTURE_MONTHS[3]: 136,
|
||||
FIXTURE_MONTHS[2]: 1524,
|
||||
FIXTURE_MONTHS[1]: 2077,
|
||||
FIXTURE_MONTHS[0]: 1858,
|
||||
},
|
||||
"Crown_CR Staging": {
|
||||
FIXTURE_MONTHS[6]: 208,
|
||||
FIXTURE_MONTHS[5]: 457,
|
||||
FIXTURE_MONTHS[4]: 671,
|
||||
FIXTURE_MONTHS[3]: 136,
|
||||
FIXTURE_MONTHS[2]: 1524,
|
||||
FIXTURE_MONTHS[1]: 2077,
|
||||
FIXTURE_MONTHS[0]: 1858,
|
||||
},
|
||||
"Crown_CR Portal Test 1": {
|
||||
FIXTURE_MONTHS[2]: 806,
|
||||
FIXTURE_MONTHS[1]: 1966,
|
||||
FIXTURE_MONTHS[0]: 2597,
|
||||
},
|
||||
"Crown_Jewels Prod": {
|
||||
FIXTURE_MONTHS[2]: 806,
|
||||
FIXTURE_MONTHS[1]: 1966,
|
||||
FIXTURE_MONTHS[0]: 2597,
|
||||
},
|
||||
"Crown_Jewels Dev": {
|
||||
FIXTURE_MONTHS[6]: 145,
|
||||
FIXTURE_MONTHS[5]: 719,
|
||||
FIXTURE_MONTHS[4]: 1243,
|
||||
FIXTURE_MONTHS[3]: 2214,
|
||||
FIXTURE_MONTHS[2]: 2959,
|
||||
FIXTURE_MONTHS[1]: 4151,
|
||||
FIXTURE_MONTHS[0]: 4260,
|
||||
},
|
||||
"NP02_Integ": {FIXTURE_MONTHS[1]: 284, FIXTURE_MONTHS[0]: 1210},
|
||||
"NP02_PreProd": {FIXTURE_MONTHS[1]: 812, FIXTURE_MONTHS[0]: 1389},
|
||||
"NP02_Prod": {FIXTURE_MONTHS[1]: 3742, FIXTURE_MONTHS[0]: 4716},
|
||||
"FM_Integ": {FIXTURE_MONTHS[1]: 1498},
|
||||
"FM_Prod": {FIXTURE_MONTHS[0]: 5686},
|
||||
}
|
||||
|
||||
REPORT_FIXTURE_MAP = {
|
||||
"A-Wing": {
|
||||
"applications": [
|
||||
MockApplication("LC04", ["Integ", "PreProd", "Prod"]),
|
||||
MockApplication("SF18", ["Integ", "PreProd", "Prod"]),
|
||||
MockApplication("Canton", ["Prod"]),
|
||||
MockApplication("BD04", ["Integ", "PreProd"]),
|
||||
MockApplication("SCV18", ["Dev"]),
|
||||
MockApplication(
|
||||
"Crown",
|
||||
[
|
||||
"CR Portal Dev",
|
||||
"CR Staging",
|
||||
"CR Portal Test 1",
|
||||
"Jewels Prod",
|
||||
"Jewels Dev",
|
||||
],
|
||||
),
|
||||
],
|
||||
"budget": 500_000,
|
||||
},
|
||||
"B-Wing": {
|
||||
"applications": [
|
||||
MockApplication("NP02", ["Integ", "PreProd", "Prod"]),
|
||||
MockApplication("FM", ["Integ", "Prod"]),
|
||||
],
|
||||
"budget": 70000,
|
||||
},
|
||||
}
|
||||
|
||||
def _rollup_application_totals(self, data):
|
||||
application_totals = {}
|
||||
for application, environments in data.items():
|
||||
application_spend = [
|
||||
(month, spend)
|
||||
for env in environments.values()
|
||||
if env
|
||||
for month, spend in env.items()
|
||||
]
|
||||
application_totals[application] = {
|
||||
month: sum([spend[1] for spend in spends])
|
||||
for month, spends in groupby(sorted(application_spend), lambda x: x[0])
|
||||
}
|
||||
|
||||
return application_totals
|
||||
|
||||
def _rollup_portfolio_totals(self, application_totals):
|
||||
monthly_spend = [
|
||||
(month, spend)
|
||||
for application in application_totals.values()
|
||||
for month, spend in application.items()
|
||||
]
|
||||
portfolio_totals = {}
|
||||
for month, spends in groupby(sorted(monthly_spend), lambda m: m[0]):
|
||||
portfolio_totals[month] = sum([spend[1] for spend in spends])
|
||||
|
||||
return portfolio_totals
|
||||
|
||||
def monthly_totals_for_environment(self, environment_id):
|
||||
"""Return the monthly totals for the specified environment.
|
||||
|
||||
Data should be in the format of a dictionary with the month as the key
|
||||
and the spend in that month as the value. For example:
|
||||
|
||||
{ "01/2018": 79.85, "02/2018": 86.54 }
|
||||
|
||||
"""
|
||||
environment_monthly_totals = self.MONTHLY_SPEND_BY_ENVIRONMENT.get(
|
||||
environment_id, {}
|
||||
).copy()
|
||||
|
||||
environment_monthly_totals["total_spend_to_date"] = sum(
|
||||
monthly_total for monthly_total in environment_monthly_totals.values()
|
||||
)
|
||||
return environment_monthly_totals
|
||||
|
||||
def monthly_totals(self, portfolio):
|
||||
"""Return month totals rolled up by environment, application, and portfolio.
|
||||
|
||||
Data should returned with three top level keys, "portfolio", "applications",
|
||||
and "environments".
|
||||
The "applications" key will have budget data per month for each application,
|
||||
The "environments" key will have budget data for each environment.
|
||||
The "portfolio" key will be total monthly spending for the portfolio.
|
||||
For example:
|
||||
|
||||
returns an array of application and environment spending for the
|
||||
portfolio. Applications and their nested environments are sorted in
|
||||
alphabetical order by name.
|
||||
[
|
||||
{
|
||||
"environments": { "X-Wing": { "Prod": { "01/2018": 75.42 } } },
|
||||
"applications": { "X-Wing": { "01/2018": 75.42 } },
|
||||
"portfolio": { "01/2018": 75.42 },
|
||||
name
|
||||
this_month
|
||||
last_month
|
||||
total
|
||||
environments [
|
||||
{
|
||||
name
|
||||
this_month
|
||||
last_month
|
||||
total
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
]
|
||||
"""
|
||||
applications = portfolio.applications
|
||||
if portfolio.name in self.REPORT_FIXTURE_MAP:
|
||||
applications = self.REPORT_FIXTURE_MAP[portfolio.name]["applications"]
|
||||
environments = {
|
||||
application.name: {
|
||||
env.name: self.monthly_totals_for_environment(env.id)
|
||||
for env in application.environments
|
||||
}
|
||||
for application in applications
|
||||
}
|
||||
|
||||
application_totals = self._rollup_application_totals(environments)
|
||||
portfolio_totals = self._rollup_portfolio_totals(application_totals)
|
||||
|
||||
return {
|
||||
"environments": environments,
|
||||
"applications": application_totals,
|
||||
"portfolio": portfolio_totals,
|
||||
}
|
||||
|
||||
def get_obligated_funds_by_JEDI_clin(self, portfolio):
|
||||
"""
|
||||
Returns a dictionary of obligated funds and spending per JEDI CLIN
|
||||
{
|
||||
JEDI_CLIN: {
|
||||
obligated_funds,
|
||||
expended_funds
|
||||
}
|
||||
}
|
||||
"""
|
||||
if portfolio.name in self.REPORT_FIXTURE_MAP:
|
||||
return_dict = {}
|
||||
for jedi_clin, clins in groupby(
|
||||
portfolio.active_clins, lambda clin: clin.jedi_clin_type
|
||||
):
|
||||
obligated_funds = sum(clin.obligated_amount for clin in clins)
|
||||
return_dict[jedi_clin.value] = {
|
||||
"obligated_funds": obligated_funds,
|
||||
"expended_funds": (
|
||||
obligated_funds * Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS)
|
||||
),
|
||||
}
|
||||
return OrderedDict(
|
||||
# 0 index for dict item, -1 for last digit of 4 digit CLIN, e.g. 0001
|
||||
sorted(return_dict.items(), key=lambda clin: clin[0][-1])
|
||||
if portfolio.name in cls.FIXTURE_SPEND_DATA:
|
||||
applications = cls.FIXTURE_SPEND_DATA[portfolio.name]["applications"]
|
||||
return sorted(
|
||||
[
|
||||
cls._get_application_monthly_totals(application)
|
||||
for application in applications
|
||||
],
|
||||
key=lambda app: app["name"],
|
||||
)
|
||||
return {}
|
||||
return []
|
||||
|
||||
def get_expired_task_orders(self, portfolio):
|
||||
return [
|
||||
@classmethod
|
||||
def _get_environment_monthly_totals(cls, environment):
|
||||
"""
|
||||
returns a dictionary that represents spending totals for an environment e.g.
|
||||
{
|
||||
name
|
||||
this_month
|
||||
last_month
|
||||
total
|
||||
}
|
||||
"""
|
||||
return {
|
||||
"name": environment["name"],
|
||||
"this_month": sum(environment["spending"]["this_month"].values()),
|
||||
"last_month": sum(environment["spending"]["last_month"].values()),
|
||||
"total": sum(environment["spending"]["total"].values()),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_application_monthly_totals(cls, application):
|
||||
"""
|
||||
returns a dictionary that represents spending totals for an application
|
||||
and its environments e.g.
|
||||
{
|
||||
"id": task_order.id,
|
||||
"number": task_order.number,
|
||||
"period_of_performance": {
|
||||
"start_date": task_order.start_date,
|
||||
"end_date": task_order.end_date,
|
||||
},
|
||||
"total_obligated_funds": task_order.total_obligated_funds,
|
||||
"expended_funds": (
|
||||
task_order.total_obligated_funds
|
||||
* Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS)
|
||||
),
|
||||
name
|
||||
this_month
|
||||
last_month
|
||||
total
|
||||
environments: [
|
||||
{
|
||||
name
|
||||
this_month
|
||||
last_month
|
||||
total
|
||||
}
|
||||
]
|
||||
}
|
||||
for task_order in portfolio.task_orders
|
||||
if task_order.is_expired
|
||||
]
|
||||
"""
|
||||
environments = sorted(
|
||||
[
|
||||
cls._get_environment_monthly_totals(env)
|
||||
for env in application["environments"]
|
||||
],
|
||||
key=lambda env: env["name"],
|
||||
)
|
||||
return {
|
||||
"name": application["name"],
|
||||
"this_month": sum(env["this_month"] for env in environments),
|
||||
"last_month": sum(env["last_month"] for env in environments),
|
||||
"total": sum(env["total"] for env in environments),
|
||||
"environments": environments,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_spending_by_JEDI_clin(cls, portfolio):
|
||||
"""
|
||||
returns an dictionary of spending per JEDI CLIN for a portfolio
|
||||
{
|
||||
jedi_clin: {
|
||||
invoiced
|
||||
estimated
|
||||
},
|
||||
}
|
||||
"""
|
||||
if portfolio.name in cls.FIXTURE_SPEND_DATA:
|
||||
CLIN_spend_dict = defaultdict(lambda: defaultdict(Decimal))
|
||||
for application in cls.FIXTURE_SPEND_DATA[portfolio.name]["applications"]:
|
||||
for environment in application["environments"]:
|
||||
for clin, spend in environment["spending"]["this_month"].items():
|
||||
CLIN_spend_dict[clin]["estimated"] += Decimal(spend)
|
||||
for clin, spend in environment["spending"]["total"].items():
|
||||
CLIN_spend_dict[clin]["invoiced"] += Decimal(spend)
|
||||
return CLIN_spend_dict
|
||||
return {}
|
||||
|
@ -3,9 +3,10 @@ from flask import current_app as app
|
||||
|
||||
from atst.database import db
|
||||
from atst.models import (
|
||||
EnvironmentRole,
|
||||
ApplicationRole,
|
||||
Environment,
|
||||
EnvironmentRole,
|
||||
Application,
|
||||
ApplicationRole,
|
||||
ApplicationRoleStatus,
|
||||
)
|
||||
from atst.domain.exceptions import NotFoundError
|
||||
@ -105,8 +106,9 @@ class EnvironmentRoles(object):
|
||||
def disable(cls, environment_role_id):
|
||||
environment_role = EnvironmentRoles.get_by_id(environment_role_id)
|
||||
|
||||
credentials = environment_role.environment.csp_credentials
|
||||
app.csp.cloud.disable_user(credentials, environment_role.csp_user_id)
|
||||
if environment_role.csp_user_id and not environment_role.environment.is_pending:
|
||||
credentials = environment_role.environment.csp_credentials
|
||||
app.csp.cloud.disable_user(credentials, environment_role.csp_user_id)
|
||||
|
||||
environment_role.status = EnvironmentRole.Status.DISABLED
|
||||
db.session.add(environment_role)
|
||||
@ -125,3 +127,15 @@ class EnvironmentRoles(object):
|
||||
.one_or_none()
|
||||
)
|
||||
return existing_env_role
|
||||
|
||||
@classmethod
|
||||
def for_user(cls, user_id, portfolio_id):
|
||||
return (
|
||||
db.session.query(EnvironmentRole)
|
||||
.join(ApplicationRole)
|
||||
.join(Application)
|
||||
.filter(Application.portfolio_id == portfolio_id)
|
||||
.filter(ApplicationRole.application_id == Application.id)
|
||||
.filter(ApplicationRole.user_id == user_id)
|
||||
.all()
|
||||
)
|
||||
|
@ -1,15 +1,44 @@
|
||||
from flask import current_app
|
||||
from itertools import groupby
|
||||
|
||||
|
||||
class Reports:
|
||||
@classmethod
|
||||
def monthly_totals(cls, portfolio):
|
||||
return current_app.csp.reports.monthly_totals(portfolio)
|
||||
def monthly_spending(cls, portfolio):
|
||||
return current_app.csp.reports.get_portfolio_monthly_spending(portfolio)
|
||||
|
||||
@classmethod
|
||||
def expired_task_orders(cls, portfolio):
|
||||
return current_app.csp.reports.get_expired_task_orders(portfolio)
|
||||
return [
|
||||
task_order for task_order in portfolio.task_orders if task_order.is_expired
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def obligated_funds_by_JEDI_clin(cls, portfolio):
|
||||
return current_app.csp.reports.get_obligated_funds_by_JEDI_clin(portfolio)
|
||||
clin_spending = current_app.csp.reports.get_spending_by_JEDI_clin(portfolio)
|
||||
active_clins = portfolio.active_clins
|
||||
for jedi_clin, clins in groupby(
|
||||
active_clins, key=lambda clin: clin.jedi_clin_type
|
||||
):
|
||||
if not clin_spending.get(jedi_clin.name):
|
||||
clin_spending[jedi_clin.name] = {}
|
||||
clin_spending[jedi_clin.name]["obligated"] = sum(
|
||||
clin.obligated_amount for clin in clins
|
||||
)
|
||||
|
||||
output = []
|
||||
for clin in clin_spending.keys():
|
||||
invoiced = clin_spending[clin].get("invoiced", 0)
|
||||
estimated = clin_spending[clin].get("estimated", 0)
|
||||
obligated = clin_spending[clin].get("obligated", 0)
|
||||
remaining = obligated - (invoiced + estimated)
|
||||
output.append(
|
||||
{
|
||||
"name": clin,
|
||||
"invoiced": invoiced,
|
||||
"estimated": estimated,
|
||||
"obligated": obligated,
|
||||
"remaining": remaining,
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
@ -1,9 +1,11 @@
|
||||
import datetime
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from atst.database import db
|
||||
from atst.models.clin import CLIN
|
||||
from atst.models.task_order import TaskOrder, SORT_ORDERING
|
||||
from . import BaseDomainClass
|
||||
from .exceptions import AlreadyExistsError
|
||||
|
||||
|
||||
class TaskOrders(BaseDomainClass):
|
||||
@ -11,12 +13,15 @@ class TaskOrders(BaseDomainClass):
|
||||
resource_name = "task_order"
|
||||
|
||||
@classmethod
|
||||
def create(cls, creator, portfolio_id, number, clins, pdf):
|
||||
task_order = TaskOrder(
|
||||
portfolio_id=portfolio_id, creator=creator, number=number, pdf=pdf
|
||||
)
|
||||
def create(cls, portfolio_id, number, clins, pdf):
|
||||
task_order = TaskOrder(portfolio_id=portfolio_id, number=number, pdf=pdf)
|
||||
db.session.add(task_order)
|
||||
db.session.commit()
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
raise AlreadyExistsError("task_order")
|
||||
|
||||
TaskOrders.create_clins(task_order.id, clins)
|
||||
|
||||
@ -37,7 +42,12 @@ class TaskOrders(BaseDomainClass):
|
||||
task_order.number = number
|
||||
db.session.add(task_order)
|
||||
|
||||
db.session.commit()
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
raise AlreadyExistsError("task_order")
|
||||
|
||||
return task_order
|
||||
|
||||
@classmethod
|
||||
@ -66,10 +76,12 @@ class TaskOrders(BaseDomainClass):
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def sort(cls, task_orders: [TaskOrder]) -> [TaskOrder]:
|
||||
# Sorts a list of task orders on two keys: status (primary) and time_created (secondary)
|
||||
by_time_created = sorted(task_orders, key=lambda to: to.time_created)
|
||||
by_status = sorted(by_time_created, key=lambda to: SORT_ORDERING.get(to.status))
|
||||
def sort_by_status(cls, task_orders):
|
||||
by_status = {status.value: [] for status in SORT_ORDERING}
|
||||
|
||||
for task_order in task_orders:
|
||||
by_status[task_order.display_status].append(task_order)
|
||||
|
||||
return by_status
|
||||
|
||||
@classmethod
|
||||
|
@ -5,6 +5,7 @@ from flask import render_template
|
||||
from jinja2 import contextfilter
|
||||
from jinja2.exceptions import TemplateNotFound
|
||||
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
||||
from decimal import DivisionByZero as DivisionByZeroException
|
||||
|
||||
|
||||
def iconSvg(name):
|
||||
@ -38,6 +39,14 @@ def usPhone(number):
|
||||
return "+1 ({}) {} - {}".format(phone[0:3], phone[3:6], phone[6:])
|
||||
|
||||
|
||||
def obligatedFundingGraphWidth(values):
|
||||
numerator, denominator = values
|
||||
try:
|
||||
return (numerator / denominator) * 100
|
||||
except DivisionByZeroException:
|
||||
return 0
|
||||
|
||||
|
||||
def formattedDate(value, formatter="%m/%d/%Y"):
|
||||
if value:
|
||||
return value.strftime(formatter)
|
||||
@ -76,6 +85,7 @@ def register_filters(app):
|
||||
app.jinja_env.filters["pageWindow"] = pageWindow
|
||||
app.jinja_env.filters["renderAuditEvent"] = renderAuditEvent
|
||||
app.jinja_env.filters["withExtraParams"] = with_extra_params
|
||||
app.jinja_env.filters["obligatedFundingGraphWidth"] = obligatedFundingGraphWidth
|
||||
|
||||
@contextfilter
|
||||
def translateWithoutCache(context, *kwargs):
|
||||
|
@ -3,112 +3,14 @@ from atst.utils.localization import translate
|
||||
|
||||
|
||||
SERVICE_BRANCHES = [
|
||||
("", "- Select -"),
|
||||
("Air Force, Department of the", "Air Force, Department of the"),
|
||||
("Army and Air Force Exchange Service", "Army and Air Force Exchange Service"),
|
||||
("Army, Department of the", "Army, Department of the"),
|
||||
("air_force", translate("forms.portfolio.defense_component.choices.air_force")),
|
||||
("army", translate("forms.portfolio.defense_component.choices.army")),
|
||||
(
|
||||
"Defense Advanced Research Applications Agency",
|
||||
"Defense Advanced Research Applications Agency",
|
||||
"marine_corps",
|
||||
translate("forms.portfolio.defense_component.choices.marine_corps"),
|
||||
),
|
||||
("Defense Commissary Agency", "Defense Commissary Agency"),
|
||||
("Defense Contract Audit Agency", "Defense Contract Audit Agency"),
|
||||
("Defense Contract Management Agency", "Defense Contract Management Agency"),
|
||||
("Defense Finance & Accounting Service", "Defense Finance & Accounting Service"),
|
||||
("Defense Health Agency", "Defense Health Agency"),
|
||||
("Defense Information System Agency", "Defense Information System Agency"),
|
||||
("Defense Intelligence Agency", "Defense Intelligence Agency"),
|
||||
("Defense Legal Services Agency", "Defense Legal Services Agency"),
|
||||
("Defense Logistics Agency", "Defense Logistics Agency"),
|
||||
("Defense Media Activity", "Defense Media Activity"),
|
||||
("Defense Micro Electronics Activity", "Defense Micro Electronics Activity"),
|
||||
("Defense POW-MIA Accounting Agency", "Defense POW-MIA Accounting Agency"),
|
||||
("Defense Security Cooperation Agency", "Defense Security Cooperation Agency"),
|
||||
("Defense Security Service", "Defense Security Service"),
|
||||
("Defense Technical Information Center", "Defense Technical Information Center"),
|
||||
(
|
||||
"Defense Technology Security Administration",
|
||||
"Defense Technology Security Administration",
|
||||
),
|
||||
("Defense Threat Reduction Agency", "Defense Threat Reduction Agency"),
|
||||
("DoD Education Activity", "DoD Education Activity"),
|
||||
("DoD Human Recourses Activity", "DoD Human Recourses Activity"),
|
||||
("DoD Inspector General", "DoD Inspector General"),
|
||||
("DoD Test Resource Management Center", "DoD Test Resource Management Center"),
|
||||
(
|
||||
"Headquarters Defense Human Resource Activity ",
|
||||
"Headquarters Defense Human Resource Activity ",
|
||||
),
|
||||
("Joint Staff", "Joint Staff"),
|
||||
("Missile Defense Agency", "Missile Defense Agency"),
|
||||
("National Defense University", "National Defense University"),
|
||||
(
|
||||
"National Geospatial Intelligence Agency (NGA)",
|
||||
"National Geospatial Intelligence Agency (NGA)",
|
||||
),
|
||||
(
|
||||
"National Oceanic and Atmospheric Administration (NOAA)",
|
||||
"National Oceanic and Atmospheric Administration (NOAA)",
|
||||
),
|
||||
("National Reconnaissance Office", "National Reconnaissance Office"),
|
||||
("National Reconnaissance Office (NRO)", "National Reconnaissance Office (NRO)"),
|
||||
("National Security Agency (NSA)", "National Security Agency (NSA)"),
|
||||
(
|
||||
"National Security Agency-Central Security Service",
|
||||
"National Security Agency-Central Security Service",
|
||||
),
|
||||
("Navy, Department of the", "Navy, Department of the"),
|
||||
("Office of Economic Adjustment", "Office of Economic Adjustment"),
|
||||
("Office of the Secretary of Defense", "Office of the Secretary of Defense"),
|
||||
("Pentagon Force Protection Agency", "Pentagon Force Protection Agency"),
|
||||
(
|
||||
"Uniform Services University of the Health Sciences",
|
||||
"Uniform Services University of the Health Sciences",
|
||||
),
|
||||
("US Cyber Command (USCYBERCOM)", "US Cyber Command (USCYBERCOM)"),
|
||||
(
|
||||
"US Special Operations Command (USSOCOM)",
|
||||
"US Special Operations Command (USSOCOM)",
|
||||
),
|
||||
("US Strategic Command (USSTRATCOM)", "US Strategic Command (USSTRATCOM)"),
|
||||
(
|
||||
"US Transportation Command (USTRANSCOM)",
|
||||
"US Transportation Command (USTRANSCOM)",
|
||||
),
|
||||
("Washington Headquarters Services", "Washington Headquarters Services"),
|
||||
]
|
||||
|
||||
APP_MIGRATION = [
|
||||
("on_premise", translate("forms.task_order.app_migration.on_premise")),
|
||||
("cloud", translate("forms.task_order.app_migration.cloud")),
|
||||
("both", translate("forms.task_order.app_migration.both")),
|
||||
("none", translate("forms.task_order.app_migration.none")),
|
||||
("not_sure", translate("forms.task_order.app_migration.not_sure")),
|
||||
]
|
||||
|
||||
APPLICATION_COMPLEXITY = [
|
||||
("storage", translate("forms.task_order.complexity.storage")),
|
||||
("data_analytics", translate("forms.task_order.complexity.data_analytics")),
|
||||
("conus", translate("forms.task_order.complexity.conus")),
|
||||
("oconus", translate("forms.task_order.complexity.oconus")),
|
||||
("tactical_edge", translate("forms.task_order.complexity.tactical_edge")),
|
||||
("not_sure", translate("forms.task_order.complexity.not_sure")),
|
||||
("other", translate("forms.task_order.complexity.other")),
|
||||
]
|
||||
|
||||
DEV_TEAM = [
|
||||
("civilians", translate("forms.task_order.dev_team.civilians")),
|
||||
("military", translate("forms.task_order.dev_team.military")),
|
||||
("contractor", translate("forms.task_order.dev_team.contractor")),
|
||||
("other", translate("forms.task_order.dev_team.other")),
|
||||
]
|
||||
|
||||
TEAM_EXPERIENCE = [
|
||||
("none", translate("forms.task_order.team_experience.none")),
|
||||
("planned", translate("forms.task_order.team_experience.planned")),
|
||||
("built_1", translate("forms.task_order.team_experience.built_1")),
|
||||
("built_3", translate("forms.task_order.team_experience.built_3")),
|
||||
("built_many", translate("forms.task_order.team_experience.built_many")),
|
||||
("navy", translate("forms.portfolio.defense_component.choices.navy")),
|
||||
("other", translate("forms.portfolio.defense_component.choices.other")),
|
||||
]
|
||||
|
||||
ENV_ROLE_NO_ACCESS = "No Access"
|
||||
|
@ -1,33 +1,25 @@
|
||||
from wtforms.fields import (
|
||||
RadioField,
|
||||
SelectField,
|
||||
SelectMultipleField,
|
||||
StringField,
|
||||
TextAreaField,
|
||||
)
|
||||
from wtforms.validators import Length, Optional
|
||||
from wtforms.validators import Length, InputRequired
|
||||
from wtforms.widgets import ListWidget, CheckboxInput
|
||||
|
||||
from .forms import BaseForm, remove_empty_string
|
||||
from .forms import BaseForm
|
||||
from atst.utils.localization import translate
|
||||
|
||||
from .data import (
|
||||
APPLICATION_COMPLEXITY,
|
||||
APP_MIGRATION,
|
||||
DEV_TEAM,
|
||||
SERVICE_BRANCHES,
|
||||
TEAM_EXPERIENCE,
|
||||
)
|
||||
from .data import SERVICE_BRANCHES
|
||||
|
||||
|
||||
class PortfolioForm(BaseForm):
|
||||
name = StringField(
|
||||
translate("forms.portfolio.name_label"),
|
||||
translate("forms.portfolio.name.label"),
|
||||
validators=[
|
||||
Length(
|
||||
min=4,
|
||||
max=100,
|
||||
message=translate("forms.portfolio.name_length_validation_message"),
|
||||
message=translate("forms.portfolio.name.length_validation_message"),
|
||||
)
|
||||
],
|
||||
)
|
||||
@ -35,78 +27,25 @@ class PortfolioForm(BaseForm):
|
||||
|
||||
class PortfolioCreationForm(BaseForm):
|
||||
name = StringField(
|
||||
translate("forms.portfolio.name_label"),
|
||||
translate("forms.portfolio.name.label"),
|
||||
validators=[
|
||||
Length(
|
||||
min=4,
|
||||
max=100,
|
||||
message=translate("forms.portfolio.name_length_validation_message"),
|
||||
message=translate("forms.portfolio.name.length_validation_message"),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
defense_component = SelectField(
|
||||
translate("forms.task_order.defense_component_label"),
|
||||
description = TextAreaField(translate("forms.portfolio.description.label"),)
|
||||
defense_component = SelectMultipleField(
|
||||
choices=SERVICE_BRANCHES,
|
||||
default="",
|
||||
filters=[remove_empty_string],
|
||||
)
|
||||
|
||||
description = TextAreaField(
|
||||
translate("forms.task_order.scope_label"),
|
||||
description=translate("forms.task_order.scope_description"),
|
||||
)
|
||||
|
||||
app_migration = RadioField(
|
||||
translate("forms.task_order.app_migration.label"),
|
||||
description=translate("forms.task_order.app_migration.description"),
|
||||
choices=APP_MIGRATION,
|
||||
default="",
|
||||
validators=[Optional()],
|
||||
)
|
||||
|
||||
native_apps = RadioField(
|
||||
translate("forms.task_order.native_apps.label"),
|
||||
description=translate("forms.task_order.native_apps.description"),
|
||||
choices=[("yes", "Yes"), ("no", "No"), ("not_sure", "Not Sure")],
|
||||
default="",
|
||||
validators=[Optional()],
|
||||
)
|
||||
|
||||
complexity = SelectMultipleField(
|
||||
translate("forms.task_order.complexity.label"),
|
||||
description=translate("forms.task_order.complexity.description"),
|
||||
choices=APPLICATION_COMPLEXITY,
|
||||
default=None,
|
||||
widget=ListWidget(prefix_label=False),
|
||||
option_widget=CheckboxInput(),
|
||||
)
|
||||
|
||||
complexity_other = StringField(
|
||||
translate("forms.task_order.complexity_other_label"),
|
||||
default=None,
|
||||
filters=[remove_empty_string],
|
||||
)
|
||||
|
||||
dev_team = SelectMultipleField(
|
||||
translate("forms.task_order.dev_team.label"),
|
||||
description=translate("forms.task_order.dev_team.description"),
|
||||
choices=DEV_TEAM,
|
||||
default=None,
|
||||
widget=ListWidget(prefix_label=False),
|
||||
option_widget=CheckboxInput(),
|
||||
)
|
||||
|
||||
dev_team_other = StringField(
|
||||
translate("forms.task_order.dev_team_other_label"),
|
||||
default=None,
|
||||
filters=[remove_empty_string],
|
||||
)
|
||||
|
||||
team_experience = RadioField(
|
||||
translate("forms.task_order.team_experience.label"),
|
||||
description=translate("forms.task_order.team_experience.description"),
|
||||
choices=TEAM_EXPERIENCE,
|
||||
default="",
|
||||
validators=[Optional()],
|
||||
validators=[
|
||||
InputRequired(
|
||||
message=translate(
|
||||
"forms.portfolio.defense_component.validation_message"
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
|
@ -7,13 +7,13 @@ from wtforms.fields import (
|
||||
HiddenField,
|
||||
)
|
||||
from wtforms.fields.html5 import DateField
|
||||
from wtforms.validators import Required, Optional, Length, NumberRange, ValidationError
|
||||
from wtforms.validators import Required, Length, NumberRange, ValidationError
|
||||
from flask_wtf import FlaskForm
|
||||
from numbers import Number
|
||||
|
||||
from .data import JEDI_CLIN_TYPES
|
||||
from .fields import SelectField
|
||||
from .forms import BaseForm
|
||||
from .forms import BaseForm, remove_empty_string
|
||||
from atst.utils.localization import translate
|
||||
from flask import current_app as app
|
||||
|
||||
@ -61,9 +61,7 @@ class CLINForm(FlaskForm):
|
||||
coerce=coerce_enum,
|
||||
)
|
||||
|
||||
number = StringField(
|
||||
label=translate("task_orders.form.clin_number_label"), validators=[Optional()]
|
||||
)
|
||||
number = StringField(label=translate("task_orders.form.clin_number_label"))
|
||||
start_date = DateField(
|
||||
translate("task_orders.form.pop_start"),
|
||||
description=translate("task_orders.form.pop_example"),
|
||||
@ -136,7 +134,10 @@ class AttachmentForm(BaseForm):
|
||||
|
||||
|
||||
class TaskOrderForm(BaseForm):
|
||||
number = StringField(label=translate("forms.task_order.number_description"))
|
||||
number = StringField(
|
||||
label=translate("forms.task_order.number_description"),
|
||||
filters=[remove_empty_string],
|
||||
)
|
||||
pdf = FormField(
|
||||
AttachmentForm,
|
||||
label=translate("task_orders.form.supporting_docs_size_limit"),
|
||||
|
@ -12,7 +12,10 @@ class ApplicationInvitation(
|
||||
__tablename__ = "application_invitations"
|
||||
|
||||
application_role_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("application_roles.id"), index=True
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("application_roles.id"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
role = relationship(
|
||||
"ApplicationRole",
|
||||
|
@ -46,7 +46,9 @@ class ApplicationRole(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=True
|
||||
)
|
||||
|
||||
status = Column(SQLAEnum(Status, native_enum=False), default=Status.PENDING)
|
||||
status = Column(
|
||||
SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False
|
||||
)
|
||||
|
||||
permission_sets = relationship(
|
||||
"PermissionSet", secondary=application_roles_permission_sets
|
||||
|
@ -23,12 +23,12 @@ class CLIN(Base, mixins.TimestampsMixin):
|
||||
task_order_id = Column(ForeignKey("task_orders.id"), nullable=False)
|
||||
task_order = relationship("TaskOrder")
|
||||
|
||||
number = Column(String, nullable=True)
|
||||
start_date = Column(Date, nullable=True)
|
||||
end_date = Column(Date, nullable=True)
|
||||
total_amount = Column(Numeric(scale=2), nullable=True)
|
||||
obligated_amount = Column(Numeric(scale=2), nullable=True)
|
||||
jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=True)
|
||||
number = Column(String, nullable=False)
|
||||
start_date = Column(Date, nullable=False)
|
||||
end_date = Column(Date, nullable=False)
|
||||
total_amount = Column(Numeric(scale=2), nullable=False)
|
||||
obligated_amount = Column(Numeric(scale=2), nullable=False)
|
||||
jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=False)
|
||||
|
||||
#
|
||||
# NOTE: For now obligated CLINS are CLIN 1 + CLIN 3
|
||||
@ -65,4 +65,6 @@ class CLIN(Base, mixins.TimestampsMixin):
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.start_date <= date.today() <= self.end_date
|
||||
return (
|
||||
self.start_date <= date.today() <= self.end_date
|
||||
) and self.task_order.signed_at
|
||||
|
@ -43,7 +43,9 @@ class EnvironmentRole(
|
||||
COMPLETED = "completed"
|
||||
DISABLED = "disabled"
|
||||
|
||||
status = Column(SQLAEnum(Status, native_enum=False), default=Status.PENDING)
|
||||
status = Column(
|
||||
SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return "<EnvironmentRole(role='{}', user='{}', environment='{}', id='{}')>".format(
|
||||
|
@ -31,23 +31,29 @@ class InvitesMixin(object):
|
||||
|
||||
@declared_attr
|
||||
def inviter_id(cls):
|
||||
return Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
||||
return Column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=False
|
||||
)
|
||||
|
||||
@declared_attr
|
||||
def inviter(cls):
|
||||
return relationship("User", foreign_keys=[cls.inviter_id])
|
||||
|
||||
status = Column(SQLAEnum(Status, native_enum=False, default=Status.PENDING))
|
||||
status = Column(
|
||||
SQLAEnum(Status, native_enum=False, default=Status.PENDING, nullable=False)
|
||||
)
|
||||
|
||||
expiration_time = Column(TIMESTAMP(timezone=True))
|
||||
expiration_time = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||
|
||||
token = Column(String, index=True, default=lambda: secrets.token_urlsafe())
|
||||
token = Column(
|
||||
String, index=True, default=lambda: secrets.token_urlsafe(), nullable=False
|
||||
)
|
||||
|
||||
email = Column(String, nullable=False)
|
||||
|
||||
dod_id = Column(String)
|
||||
first_name = Column(String)
|
||||
last_name = Column(String)
|
||||
dod_id = Column(String, nullable=False)
|
||||
first_name = Column(String, nullable=False)
|
||||
last_name = Column(String, nullable=False)
|
||||
phone_number = Column(String)
|
||||
phone_ext = Column(String)
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
from sqlalchemy import Column, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.types import ARRAY
|
||||
from itertools import chain
|
||||
|
||||
from atst.models.base import Base
|
||||
@ -18,17 +17,11 @@ class Portfolio(
|
||||
__tablename__ = "portfolios"
|
||||
|
||||
id = types.Id()
|
||||
name = Column(String)
|
||||
defense_component = Column(String) # Department of Defense Component
|
||||
|
||||
app_migration = Column(String) # App Migration
|
||||
complexity = Column(ARRAY(String)) # Application Complexity
|
||||
complexity_other = Column(String)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(String)
|
||||
dev_team = Column(ARRAY(String)) # Development Team
|
||||
dev_team_other = Column(String)
|
||||
native_apps = Column(String) # Native Apps
|
||||
team_experience = Column(String) # Team Experience
|
||||
defense_component = Column(
|
||||
String, nullable=False
|
||||
) # Department of Defense Component
|
||||
|
||||
applications = relationship(
|
||||
"Application",
|
||||
|
@ -12,7 +12,7 @@ class PortfolioInvitation(
|
||||
__tablename__ = "portfolio_invitations"
|
||||
|
||||
portfolio_role_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("portfolio_roles.id"), index=True
|
||||
UUID(as_uuid=True), ForeignKey("portfolio_roles.id"), index=True, nullable=False
|
||||
)
|
||||
role = relationship(
|
||||
"PortfolioRole",
|
||||
|
@ -52,7 +52,9 @@ class PortfolioRole(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=True
|
||||
)
|
||||
|
||||
status = Column(SQLAEnum(Status, native_enum=False), default=Status.PENDING)
|
||||
status = Column(
|
||||
SQLAEnum(Status, native_enum=False), default=Status.PENDING, nullable=False
|
||||
)
|
||||
|
||||
permission_sets = relationship(
|
||||
"PermissionSet", secondary=portfolio_roles_permission_sets
|
||||
|
@ -1,5 +1,5 @@
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
@ -17,15 +17,16 @@ class Status(Enum):
|
||||
ACTIVE = "Active"
|
||||
UPCOMING = "Upcoming"
|
||||
EXPIRED = "Expired"
|
||||
UNSIGNED = "Not signed"
|
||||
UNSIGNED = "Unsigned"
|
||||
|
||||
|
||||
SORT_ORDERING = {
|
||||
status: order
|
||||
for (order, status) in enumerate(
|
||||
[Status.DRAFT, Status.ACTIVE, Status.UPCOMING, Status.EXPIRED, Status.UNSIGNED]
|
||||
)
|
||||
}
|
||||
SORT_ORDERING = [
|
||||
Status.ACTIVE,
|
||||
Status.DRAFT,
|
||||
Status.UPCOMING,
|
||||
Status.EXPIRED,
|
||||
Status.UNSIGNED,
|
||||
]
|
||||
|
||||
|
||||
class TaskOrder(Base, mixins.TimestampsMixin):
|
||||
@ -33,15 +34,12 @@ class TaskOrder(Base, mixins.TimestampsMixin):
|
||||
|
||||
id = types.Id()
|
||||
|
||||
portfolio_id = Column(ForeignKey("portfolios.id"))
|
||||
portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False)
|
||||
portfolio = relationship("Portfolio")
|
||||
|
||||
user_id = Column(ForeignKey("users.id"))
|
||||
creator = relationship("User", foreign_keys="TaskOrder.user_id")
|
||||
|
||||
pdf_attachment_id = Column(ForeignKey("attachments.id"))
|
||||
_pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id])
|
||||
number = Column(String) # Task Order Number
|
||||
number = Column(String, unique=True,) # Task Order Number
|
||||
signer_dod_id = Column(String)
|
||||
signed_at = Column(DateTime)
|
||||
|
||||
@ -134,12 +132,11 @@ class TaskOrder(Base, mixins.TimestampsMixin):
|
||||
|
||||
@property
|
||||
def start_date(self):
|
||||
return min((c.start_date for c in self.clins), default=self.time_created.date())
|
||||
return min((c.start_date for c in self.clins), default=None)
|
||||
|
||||
@property
|
||||
def end_date(self):
|
||||
default_end_date = self.start_date + timedelta(days=1)
|
||||
return max((c.end_date for c in self.clins), default=default_end_date)
|
||||
return max((c.end_date for c in self.clins), default=None)
|
||||
|
||||
@property
|
||||
def days_to_expiration(self):
|
||||
@ -173,6 +170,11 @@ class TaskOrder(Base, mixins.TimestampsMixin):
|
||||
# Faked for display purposes
|
||||
return 50
|
||||
|
||||
@property
|
||||
def invoiced_funds(self):
|
||||
# TODO: implement this using reporting data from the CSP
|
||||
return self.total_obligated_funds * Decimal(0.75)
|
||||
|
||||
@property
|
||||
def display_status(self):
|
||||
return self.status.value
|
||||
|
@ -56,8 +56,8 @@ class User(
|
||||
|
||||
email = Column(String)
|
||||
dod_id = Column(String, unique=True, nullable=False)
|
||||
first_name = Column(String)
|
||||
last_name = Column(String)
|
||||
first_name = Column(String, nullable=False)
|
||||
last_name = Column(String, nullable=False)
|
||||
phone_number = Column(String)
|
||||
phone_ext = Column(String)
|
||||
service_branch = Column(String)
|
||||
|
@ -1,7 +1,8 @@
|
||||
from flask import render_template
|
||||
from flask import render_template, g
|
||||
|
||||
from .blueprint import applications_bp
|
||||
from atst.domain.authz.decorator import user_can_access_decorator as user_can
|
||||
from atst.domain.environment_roles import EnvironmentRoles
|
||||
from atst.models.permissions import Permissions
|
||||
|
||||
|
||||
@ -23,4 +24,11 @@ def has_portfolio_applications(_user, portfolio=None, **_kwargs):
|
||||
message="view portfolio applications",
|
||||
)
|
||||
def portfolio_applications(portfolio_id):
|
||||
return render_template("applications/index.html")
|
||||
user_env_roles = EnvironmentRoles.for_user(g.current_user.id, portfolio_id)
|
||||
environment_access = {
|
||||
env_role.environment_id: env_role.role for env_role in user_env_roles
|
||||
}
|
||||
|
||||
return render_template(
|
||||
"applications/index.html", environment_access=environment_access
|
||||
)
|
||||
|
@ -20,6 +20,7 @@ from atst.domain.permission_sets import PermissionSets
|
||||
from atst.utils.flash import formatted_flash as flash
|
||||
from atst.utils.localization import translate
|
||||
from atst.jobs import send_mail
|
||||
from atst.routes.errors import log_error
|
||||
|
||||
|
||||
def get_environments_obj_for_app(application):
|
||||
@ -234,7 +235,8 @@ def handle_update_member(application_id, application_role_id, form_data):
|
||||
|
||||
flash("application_member_updated", user_name=app_role.user_name)
|
||||
|
||||
except GeneralCSPException:
|
||||
except GeneralCSPException as exc:
|
||||
log_error(exc)
|
||||
flash(
|
||||
"application_member_update_error", user_name=app_role.user_name,
|
||||
)
|
||||
|
@ -1,4 +1,4 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from flask import redirect, render_template, url_for, request as http_request, g
|
||||
|
||||
@ -11,21 +11,10 @@ from atst.domain.authz.decorator import user_can_access_decorator as user_can
|
||||
from atst.utils.flash import formatted_flash as flash
|
||||
|
||||
|
||||
@portfolios_bp.route("/portfolios")
|
||||
def portfolios():
|
||||
portfolios = Portfolios.for_user(g.current_user)
|
||||
|
||||
if portfolios:
|
||||
return render_template("portfolios/index.html", page=5, portfolios=portfolios)
|
||||
else:
|
||||
return render_template("portfolios/blank_slate.html")
|
||||
|
||||
|
||||
@portfolios_bp.route("/portfolios/new")
|
||||
def new_portfolio():
|
||||
def new_portfolio_step_1():
|
||||
form = PortfolioCreationForm()
|
||||
|
||||
return render_template("portfolios/new.html", form=form)
|
||||
return render_template("portfolios/new/step_1.html", form=form)
|
||||
|
||||
|
||||
@portfolios_bp.route("/portfolios", methods=["POST"])
|
||||
@ -38,16 +27,19 @@ def create_portfolio():
|
||||
url_for("applications.portfolio_applications", portfolio_id=portfolio.id)
|
||||
)
|
||||
else:
|
||||
return render_template("portfolios/new.html", form=form), 400
|
||||
return render_template("portfolios/new/step_1.html", form=form), 400
|
||||
|
||||
|
||||
@portfolios_bp.route("/portfolios/<portfolio_id>/reports")
|
||||
@user_can(Permissions.VIEW_PORTFOLIO_REPORTS, message="view portfolio reports")
|
||||
def reports(portfolio_id):
|
||||
portfolio = Portfolios.get(g.current_user, portfolio_id)
|
||||
today = date.today()
|
||||
current_month = date(int(today.year), int(today.month), 15)
|
||||
prev_month = current_month - timedelta(days=28)
|
||||
|
||||
current_obligated_funds = Reports.obligated_funds_by_JEDI_clin(portfolio)
|
||||
|
||||
if any(map(lambda clin: clin["remaining"] < 0, current_obligated_funds)):
|
||||
flash("insufficient_funds")
|
||||
|
||||
# wrapped in str() because the sum of obligated funds returns a Decimal object
|
||||
total_portfolio_value = str(
|
||||
sum(
|
||||
@ -59,12 +51,10 @@ def reports(portfolio_id):
|
||||
"portfolios/reports/index.html",
|
||||
portfolio=portfolio,
|
||||
total_portfolio_value=total_portfolio_value,
|
||||
current_obligated_funds=Reports.obligated_funds_by_JEDI_clin(portfolio),
|
||||
current_obligated_funds=current_obligated_funds,
|
||||
expired_task_orders=Reports.expired_task_orders(portfolio),
|
||||
monthly_totals=Reports.monthly_totals(portfolio),
|
||||
current_month=current_month,
|
||||
prev_month=prev_month,
|
||||
now=datetime.now(), # mocked datetime of reporting data retrival
|
||||
monthly_spending=Reports.monthly_spending(portfolio),
|
||||
retrieved=datetime.now(), # mocked datetime of reporting data retrival
|
||||
)
|
||||
|
||||
|
||||
|
@ -6,7 +6,6 @@ from atst.domain.portfolios import Portfolios
|
||||
from atst.domain.task_orders import TaskOrders
|
||||
from atst.forms.task_order import SignatureForm
|
||||
from atst.models import Permissions
|
||||
from atst.models.task_order import Status as TaskOrderStatus
|
||||
|
||||
|
||||
@task_orders_bp.route("/task_orders/<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")
|
||||
def portfolio_funding(portfolio_id):
|
||||
portfolio = Portfolios.get(g.current_user, portfolio_id)
|
||||
task_orders = TaskOrders.sort(portfolio.task_orders)
|
||||
label_colors = {
|
||||
TaskOrderStatus.DRAFT: "warning",
|
||||
TaskOrderStatus.ACTIVE: "success",
|
||||
TaskOrderStatus.UPCOMING: "info",
|
||||
TaskOrderStatus.EXPIRED: "error",
|
||||
TaskOrderStatus.UNSIGNED: "purple",
|
||||
}
|
||||
task_orders = TaskOrders.sort_by_status(portfolio.task_orders)
|
||||
to_count = len(portfolio.task_orders)
|
||||
# TODO: Get expended amount from the CSP
|
||||
return render_template(
|
||||
"task_orders/index.html", task_orders=task_orders, label_colors=label_colors
|
||||
"task_orders/index.html", task_orders=task_orders, to_count=to_count
|
||||
)
|
||||
|
@ -10,7 +10,7 @@ from flask import (
|
||||
|
||||
from .blueprint import task_orders_bp
|
||||
from atst.domain.authz.decorator import user_can_access_decorator as user_can
|
||||
from atst.domain.exceptions import NoAccessError
|
||||
from atst.domain.exceptions import NoAccessError, AlreadyExistsError
|
||||
from atst.domain.task_orders import TaskOrders
|
||||
from atst.forms.task_order import TaskOrderForm, SignatureForm
|
||||
from atst.models.permissions import Permissions
|
||||
@ -50,7 +50,26 @@ def render_task_orders_edit(
|
||||
return render_template(template, **render_args)
|
||||
|
||||
|
||||
def update_task_order(
|
||||
def update_task_order(form, portfolio_id=None, task_order_id=None, flash_invalid=True):
|
||||
if form.validate(flash_invalid=flash_invalid):
|
||||
task_order = None
|
||||
try:
|
||||
if task_order_id:
|
||||
task_order = TaskOrders.update(task_order_id, **form.data)
|
||||
portfolio_id = task_order.portfolio_id
|
||||
else:
|
||||
task_order = TaskOrders.create(portfolio_id, **form.data)
|
||||
|
||||
return task_order
|
||||
|
||||
except AlreadyExistsError:
|
||||
flash("task_order_number_error", to_number=form.data["number"])
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def update_and_render_next(
|
||||
form_data, next_page, current_template, portfolio_id=None, task_order_id=None
|
||||
):
|
||||
form = None
|
||||
@ -60,14 +79,8 @@ def update_task_order(
|
||||
else:
|
||||
form = TaskOrderForm(form_data)
|
||||
|
||||
if form.validate():
|
||||
task_order = None
|
||||
if task_order_id:
|
||||
task_order = TaskOrders.update(task_order_id, **form.data)
|
||||
portfolio_id = task_order.portfolio_id
|
||||
else:
|
||||
task_order = TaskOrders.create(g.current_user, portfolio_id, **form.data)
|
||||
|
||||
task_order = update_task_order(form, portfolio_id, task_order_id)
|
||||
if task_order:
|
||||
return redirect(url_for(next_page, task_order_id=task_order.id))
|
||||
else:
|
||||
return (
|
||||
@ -149,7 +162,7 @@ def submit_form_step_one_add_pdf(portfolio_id=None, task_order_id=None):
|
||||
next_page = "task_orders.form_step_two_add_number"
|
||||
current_template = "task_orders/step_1.html"
|
||||
|
||||
return update_task_order(
|
||||
return update_and_render_next(
|
||||
form_data,
|
||||
next_page,
|
||||
current_template,
|
||||
@ -176,14 +189,8 @@ def cancel_edit(task_order_id=None, portfolio_id=None):
|
||||
else:
|
||||
form = TaskOrderForm(form_data)
|
||||
|
||||
if form.validate(flash_invalid=False):
|
||||
task_order = None
|
||||
if task_order_id:
|
||||
task_order = TaskOrders.update(task_order_id, **form.data)
|
||||
else:
|
||||
task_order = TaskOrders.create(
|
||||
g.current_user, portfolio_id, **form.data
|
||||
)
|
||||
update_task_order(form, portfolio_id, task_order_id, flash_invalid=False)
|
||||
|
||||
elif not save and task_order_id:
|
||||
TaskOrders.delete(task_order_id)
|
||||
|
||||
@ -207,7 +214,7 @@ def submit_form_step_two_add_number(task_order_id):
|
||||
next_page = "task_orders.form_step_three_add_clins"
|
||||
current_template = "task_orders/step_2.html"
|
||||
|
||||
return update_task_order(
|
||||
return update_and_render_next(
|
||||
form_data, next_page, current_template, task_order_id=task_order_id
|
||||
)
|
||||
|
||||
@ -227,7 +234,7 @@ def submit_form_step_three_add_clins(task_order_id):
|
||||
next_page = "task_orders.form_step_four_review"
|
||||
current_template = "task_orders/step_3.html"
|
||||
|
||||
return update_task_order(
|
||||
return update_and_render_next(
|
||||
form_data, next_page, current_template, task_order_id=task_order_id
|
||||
)
|
||||
|
||||
|
@ -96,6 +96,11 @@ MESSAGES = {
|
||||
"message_template": "<p>Please see below.</p>",
|
||||
"category": "error",
|
||||
},
|
||||
"insufficient_funds": {
|
||||
"title_template": "Insufficient Funds",
|
||||
"message_template": "",
|
||||
"category": "warning",
|
||||
},
|
||||
"logged_out": {
|
||||
"title_template": translate("flash.logged_out"),
|
||||
"message_template": """
|
||||
@ -160,6 +165,11 @@ MESSAGES = {
|
||||
"message_template": translate("task_orders.form.draft_alert_message"),
|
||||
"category": "warning",
|
||||
},
|
||||
"task_order_number_error": {
|
||||
"title_template": "",
|
||||
"message_template": """{{ 'flash.task_order_number_error.message' | translate({ 'to_number': to_number }) }}""",
|
||||
"category": "error",
|
||||
},
|
||||
"task_order_submitted": {
|
||||
"title_template": "Your Task Order has been uploaded successfully.",
|
||||
"message_template": """
|
||||
|
@ -1,5 +1,8 @@
|
||||
[default]
|
||||
ASSETS_URL
|
||||
AZURE_ACCOUNT_NAME
|
||||
AZURE_STORAGE_KEY
|
||||
AZURE_TO_BUCKET_NAME
|
||||
BLOB_STORAGE_URL=http://localhost:8000/
|
||||
CAC_URL = http://localhost:8000/login-redirect
|
||||
CA_CHAIN = ssl/server-certs/ca-chain.pem
|
||||
@ -15,6 +18,11 @@ DISABLE_CRL_CHECK = false
|
||||
ENVIRONMENT = dev
|
||||
LIMIT_CONCURRENT_SESSIONS = false
|
||||
LOG_JSON = false
|
||||
MAIL_PASSWORD
|
||||
MAIL_PORT
|
||||
MAIL_SENDER
|
||||
MAIL_SERVER
|
||||
MAIL_TLS
|
||||
PERMANENT_SESSION_LIFETIME = 1800
|
||||
PGDATABASE = atat
|
||||
PGHOST = localhost
|
||||
@ -24,7 +32,10 @@ PGSSLMODE = prefer
|
||||
PGSSLROOTCERT
|
||||
PGUSER = postgres
|
||||
PORT=8000
|
||||
REDIS_URI = redis://localhost:6379
|
||||
REDIS_HOST=localhost:6379
|
||||
REDIS_PASSWORD
|
||||
REDIS_TLS=False
|
||||
REDIS_USER
|
||||
SECRET_KEY = change_me_into_something_secret
|
||||
SERVER_NAME
|
||||
SESSION_COOKIE_NAME=atat
|
||||
|
@ -1,8 +1,6 @@
|
||||
[default]
|
||||
DEBUG = true
|
||||
PGHOST = postgreshost
|
||||
PGDATABASE = atat_test
|
||||
REDIS_URI = redis://redishost:6379
|
||||
CRL_STORAGE_CONTAINER = tests/fixtures/crl
|
||||
WTF_CSRF_ENABLED = false
|
||||
CSP=mock-test
|
||||
DEBUG = true
|
||||
PGDATABASE = atat_test
|
||||
WTF_CSRF_ENABLED = false
|
||||
|
101
deploy/README.md
101
deploy/README.md
@ -14,6 +14,7 @@ The production configuration (azure.atat.code.mil, currently) is reflected in th
|
||||
- AUTH_DOMAIN: The host domain for the authentication endpoint for the environment.
|
||||
- KV_MI_ID: the fully qualified id (path) of the managed identity for the key vault (instructions on retrieving this are down in section on [Setting up FlexVol](#configuring-the-identity)). Example: /subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/RESOURCE_GROUP_NAME/providers/Microsoft.ManagedIdentity/userAssignedIdentities/MANAGED_IDENTITY_NAME
|
||||
- KV_MI_CLIENT_ID: The client id of the managed identity for the key vault. This is a GUID.
|
||||
- TENANT_ID: The id of the active directory tenant in which the cluster and it's associated users exist. This is a GUID.
|
||||
|
||||
We use envsubst to substitute values for these variables. There is a wrapper script (script/k8s_config) that will output the compiled configuration, using a combination of kustomize and envsubst.
|
||||
|
||||
@ -36,35 +37,6 @@ If you are satisfied with the output from the diff, you can apply the new config
|
||||
|
||||
## Secrets and Configuration
|
||||
|
||||
### atst-overrides.ini
|
||||
|
||||
Production configuration values are provided to the ATAT Flask app by writing an `atst-overrides.ini` file to the running Docker container. This file is stored as a Kubernetes secret. It contains configuration information for the database connection, mailer, etc.
|
||||
|
||||
To update the configuration, you can do the following:
|
||||
|
||||
```
|
||||
kubectl -n atat get secret atst-config-ini -o=jsonpath='{.data.override\.ini}' | base64 --decode > override.ini
|
||||
```
|
||||
|
||||
This base64 decodes the secret and writes it to a local file called `override.ini`. Make any necessary config changes to that file.
|
||||
|
||||
To apply the new config, first delete the existing copy of the secret:
|
||||
|
||||
```
|
||||
kubectl -n atat delete secret atst-config-ini
|
||||
```
|
||||
|
||||
Then create a new copy of the secret from your updated copy:
|
||||
|
||||
```
|
||||
kubectl -n atat create secret generic atst-config-ini --from-file=./override.ini
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Be careful not to check the override.ini file into source control.
|
||||
- Be careful not to overwrite one CSP cluster's config with the other's. This will break everything.
|
||||
|
||||
### nginx-htpasswd
|
||||
|
||||
If the site is running in dev mode, the `/login-dev` endpoint is available. This endpoint is protected by basic HTTP auth. To create a new password file, run:
|
||||
@ -169,13 +141,40 @@ Then:
|
||||
kubectl -n atat create secret tls azure-atat-code-mil-tls --key="[path to the private key]" --cert="[path to the full chain]"
|
||||
```
|
||||
|
||||
### Create the Diffie-Hellman parameters
|
||||
|
||||
Diffie-Hellman parameters allow per-session encryption of SSL traffic to help improve security. We currently store our parameters in KeyVault, the value can be updated using the following command. Note: Generating the new paramter can take over 10 minutes and there won't be any output while it's running.
|
||||
```
|
||||
az keyvault secret set --vault-name <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
|
||||
|
||||
## Preparing Azure Environment
|
||||
|
||||
A Key Vault will need to be created. Save it's full id (the full path) for use later.
|
||||
A Key Vault will need to be created. Save its full id (the full path) for use later.
|
||||
|
||||
## Preparing Cluster
|
||||
|
||||
@ -217,3 +216,45 @@ Example values:
|
||||
|
||||
5. The file `deploy/azure/aadpodidentity.yml` is templated via Kustomize, so you'll need to include clientId (as `KV_MI_CLIENT_ID`) and id (as `KV_MI_ID`) of the managed identity as part of the call to Kustomize.
|
||||
|
||||
## Using the FlexVol
|
||||
|
||||
There are 3 steps to using the FlexVol to access secrets from KeyVault
|
||||
|
||||
1. For the resource in which you would like to mount a FlexVol, add a metadata label with the selector from `aadpodidentity.yml`
|
||||
```
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
role: web
|
||||
aadpodidbinding: atat-kv-id-binding
|
||||
```
|
||||
|
||||
2. Register the FlexVol as a mount and specifiy which secrets you want to mount, along with the file name they should have. The `keyvaultobjectnames`, `keyvaultobjectaliases`, and `keyvaultobjecttypes` correspond to one another, positionally. They are passed as semicolon delimited strings, examples below.
|
||||
|
||||
```
|
||||
- name: volume-of-secrets
|
||||
flexVolume:
|
||||
driver: "azure/kv"
|
||||
options:
|
||||
usepodidentity: "true"
|
||||
keyvaultname: "<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
|
||||
```
|
||||
|
@ -6,15 +6,30 @@ metadata:
|
||||
namespace: atat
|
||||
data:
|
||||
ASSETS_URL: https://atat-cdn.azureedge.net/
|
||||
AZURE_ACCOUNT_NAME: atat
|
||||
AZURE_TO_BUCKET_NAME: task-order-pdfs
|
||||
BLOB_STORAGE_URL: https://atat.blob.core.windows.net/
|
||||
CELERY_DEFAULT_QUEUE: celery-master
|
||||
CAC_URL: https://auth-staging.atat.code.mil/login-redirect
|
||||
CDN_ORIGIN: https://azure.atat.code.mil
|
||||
CELERY_DEFAULT_QUEUE: celery-master
|
||||
CSP: azure
|
||||
DEBUG: "0"
|
||||
FLASK_ENV: master
|
||||
LOG_JSON: "true"
|
||||
OVERRIDE_CONFIG_FULLPATH: /opt/atat/atst/atst-overrides.ini
|
||||
MAIL_PORT: "587"
|
||||
MAIL_SENDER: postmaster@atat.code.mil
|
||||
MAIL_SERVER: smtp.mailgun.org
|
||||
MAIL_TLS: "true"
|
||||
OVERRIDE_CONFIG_DIRECTORY: /config
|
||||
PGAPPNAME: atst
|
||||
PGDATABASE: staging
|
||||
PGHOST: atat-db.postgres.database.azure.com
|
||||
PGPORT: "5432"
|
||||
PGSSLMODE: verify-full
|
||||
PGSSLROOTCERT: /opt/atat/atst/ssl/pgsslrootcert.crt
|
||||
PGUSER: atat_master@atat-db
|
||||
REDIS_HOST: atat.redis.cache.windows.net:6380
|
||||
REDIS_TLS: "true"
|
||||
STATIC_URL: https://atat-cdn.azureedge.net/static/
|
||||
TZ: UTC
|
||||
UWSGI_CONFIG_FULLPATH: /opt/atat/atst/uwsgi.ini
|
||||
|
@ -5,8 +5,10 @@ metadata:
|
||||
name: atst-nginx
|
||||
namespace: atat
|
||||
data:
|
||||
nginx-config: |-
|
||||
atst.conf: |-
|
||||
server {
|
||||
access_log /var/log/nginx/access.log json;
|
||||
|
||||
listen ${PORT_PREFIX}342;
|
||||
server_name ${MAIN_DOMAIN};
|
||||
root /usr/share/nginx/html;
|
||||
@ -18,6 +20,8 @@ data:
|
||||
}
|
||||
}
|
||||
server {
|
||||
access_log /var/log/nginx/access.log json;
|
||||
|
||||
listen ${PORT_PREFIX}343;
|
||||
server_name ${AUTH_DOMAIN};
|
||||
root /usr/share/nginx/html;
|
||||
@ -29,12 +33,17 @@ data:
|
||||
}
|
||||
}
|
||||
server {
|
||||
access_log /var/log/nginx/access.log json;
|
||||
|
||||
server_name ${MAIN_DOMAIN};
|
||||
# access_log /var/log/nginx/access.log json;
|
||||
listen ${PORT_PREFIX}442 ssl;
|
||||
listen [::]:${PORT_PREFIX}442 ssl ipv6only=on;
|
||||
ssl_certificate /etc/ssl/private/atat.crt;
|
||||
ssl_certificate_key /etc/ssl/private/atat.key;
|
||||
ssl_certificate /etc/ssl/atat.crt;
|
||||
ssl_certificate_key /etc/ssl/atat.key;
|
||||
# additional SSL/TLS settings
|
||||
include /etc/nginx/snippets/ssl.conf;
|
||||
|
||||
location /login-redirect {
|
||||
return 301 https://auth-azure.atat.code.mil$request_uri;
|
||||
}
|
||||
@ -58,18 +67,20 @@ data:
|
||||
}
|
||||
}
|
||||
server {
|
||||
# access_log /var/log/nginx/access.log json;
|
||||
access_log /var/log/nginx/access.log json;
|
||||
|
||||
server_name ${AUTH_DOMAIN};
|
||||
listen ${PORT_PREFIX}443 ssl;
|
||||
listen [::]:${PORT_PREFIX}443 ssl ipv6only=on;
|
||||
ssl_certificate /etc/ssl/private/atat.crt;
|
||||
ssl_certificate_key /etc/ssl/private/atat.key;
|
||||
ssl_certificate /etc/ssl/atat.crt;
|
||||
ssl_certificate_key /etc/ssl/atat.key;
|
||||
# Request and validate client certificate
|
||||
ssl_verify_client on;
|
||||
ssl_verify_depth 10;
|
||||
ssl_client_certificate /etc/ssl/client-ca-bundle.pem;
|
||||
# Guard against HTTPS -> HTTP downgrade
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; always";
|
||||
# additional SSL/TLS settings
|
||||
include /etc/nginx/snippets/ssl.conf;
|
||||
|
||||
location / {
|
||||
return 301 https://azure.atat.code.mil$request_uri;
|
||||
}
|
||||
@ -88,3 +99,18 @@ data:
|
||||
uwsgi_param HTTP_X_REQUEST_ID $request_id;
|
||||
}
|
||||
}
|
||||
00json_log.conf: |-
|
||||
log_format json escape=json
|
||||
'{'
|
||||
'"timestamp":"$time_iso8601",'
|
||||
'"msec":"$msec",'
|
||||
'"request_id":"$request_id",'
|
||||
'"remote_addr":"$remote_addr",'
|
||||
'"remote_user":"$remote_user",'
|
||||
'"request":"$request",'
|
||||
'"status":$status,'
|
||||
'"body_bytes_sent":$body_bytes_sent,'
|
||||
'"referer":"$http_referer",'
|
||||
'"user_agent":"$http_user_agent",'
|
||||
'"http_x_forwarded_for":"$http_x_forwarded_for"'
|
||||
'}';
|
||||
|
@ -5,9 +5,25 @@ metadata:
|
||||
name: atst-worker-envvars
|
||||
namespace: atat
|
||||
data:
|
||||
AZURE_ACCOUNT_NAME: atat
|
||||
AZURE_TO_BUCKET_NAME: task-order-pdfs
|
||||
CAC_URL: https://auth-staging.atat.code.mil/login-redirect
|
||||
CELERY_DEFAULT_QUEUE: celery-master
|
||||
DISABLE_CRL_CHECK: "True"
|
||||
DEBUG: "0"
|
||||
DISABLE_CRL_CHECK: "true"
|
||||
MAIL_PORT: "587"
|
||||
MAIL_SENDER: postmaster@atat.code.mil
|
||||
MAIL_SERVER: smtp.mailgun.org
|
||||
MAIL_TLS: "true"
|
||||
OVERRIDE_CONFIG_DIRECTORY: /config
|
||||
PGAPPNAME: atst
|
||||
PGDATABASE: staging
|
||||
PGHOST: atat-db.postgres.database.azure.com
|
||||
PGPORT: "5432"
|
||||
PGSSLMODE: verify-full
|
||||
PGSSLROOTCERT: /opt/atat/atst/ssl/pgsslrootcert.crt
|
||||
PGUSER: atat_master@atat-db
|
||||
REDIS_HOST: atat.redis.cache.windows.net:6380
|
||||
REDIS_TLS: "true"
|
||||
SERVER_NAME: azure.atat.code.mil
|
||||
TZ: UTC
|
||||
|
@ -23,6 +23,7 @@ spec:
|
||||
labels:
|
||||
app: atst
|
||||
role: web
|
||||
aadpodidbinding: atat-kv-id-binding
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 101
|
||||
@ -30,12 +31,9 @@ spec:
|
||||
- name: atst
|
||||
image: $CONTAINER_IMAGE
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: atst-envvars
|
||||
- configMapRef:
|
||||
name: atst-envvars
|
||||
volumeMounts:
|
||||
- name: atst-config
|
||||
mountPath: "/opt/atat/atst/atst-overrides.ini"
|
||||
subPath: atst-overrides.ini
|
||||
- name: nginx-client-ca-bundle
|
||||
mountPath: "/opt/atat/atst/ssl/server-certs/ca-chain.pem"
|
||||
subPath: client-ca-bundle.pem
|
||||
@ -49,6 +47,8 @@ spec:
|
||||
- name: uwsgi-config
|
||||
mountPath: "/opt/atat/atst/uwsgi.ini"
|
||||
subPath: uwsgi.ini
|
||||
- name: flask-secret
|
||||
mountPath: "/config"
|
||||
- name: nginx
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
@ -62,37 +62,32 @@ spec:
|
||||
name: auth
|
||||
volumeMounts:
|
||||
- name: nginx-config
|
||||
mountPath: "/etc/nginx/conf.d/atst.conf"
|
||||
subPath: atst.conf
|
||||
mountPath: "/etc/nginx/conf.d/"
|
||||
- name: uwsgi-socket-dir
|
||||
mountPath: "/var/run/uwsgi"
|
||||
- name: nginx-htpasswd
|
||||
mountPath: "/etc/nginx/.htpasswd"
|
||||
subPath: .htpasswd
|
||||
- name: tls
|
||||
mountPath: "/etc/ssl/private"
|
||||
- name: nginx-client-ca-bundle
|
||||
mountPath: "/etc/ssl/"
|
||||
mountPath: "/etc/ssl/client-ca-bundle.pem"
|
||||
subPath: "client-ca-bundle.pem"
|
||||
- name: acme
|
||||
mountPath: "/usr/share/nginx/html/.well-known/acme-challenge/"
|
||||
- name: snippets
|
||||
mountPath: "/etc/nginx/snippets/"
|
||||
- name: nginx-secret
|
||||
mountPath: "/etc/ssl/"
|
||||
volumes:
|
||||
- name: atst-config
|
||||
secret:
|
||||
secretName: atst-config-ini
|
||||
items:
|
||||
- key: override.ini
|
||||
path: atst-overrides.ini
|
||||
mode: 0644
|
||||
- name: nginx-client-ca-bundle
|
||||
configMap:
|
||||
name: nginx-client-ca-bundle
|
||||
defaultMode: 0666
|
||||
defaultMode: 0444
|
||||
items:
|
||||
- key: "client-ca-bundle.pem"
|
||||
path: "client-ca-bundle.pem"
|
||||
- name: nginx-config
|
||||
configMap:
|
||||
name: atst-nginx
|
||||
items:
|
||||
- key: nginx-config
|
||||
path: atst.conf
|
||||
- name: uwsgi-socket-dir
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
@ -100,19 +95,9 @@ spec:
|
||||
secret:
|
||||
secretName: atst-nginx-htpasswd
|
||||
items:
|
||||
- key: htpasswd
|
||||
path: .htpasswd
|
||||
mode: 0640
|
||||
- name: tls
|
||||
secret:
|
||||
secretName: azure-atat-code-mil-tls
|
||||
items:
|
||||
- key: tls.crt
|
||||
path: atat.crt
|
||||
mode: 0644
|
||||
- key: tls.key
|
||||
path: atat.key
|
||||
mode: 0640
|
||||
- key: htpasswd
|
||||
path: .htpasswd
|
||||
mode: 0640
|
||||
- name: crls-vol
|
||||
persistentVolumeClaim:
|
||||
claimName: crls-vol-claim
|
||||
@ -120,9 +105,9 @@ spec:
|
||||
configMap:
|
||||
name: pgsslrootcert
|
||||
items:
|
||||
- key: cert
|
||||
path: pgsslrootcert.crt
|
||||
mode: 0666
|
||||
- key: cert
|
||||
path: pgsslrootcert.crt
|
||||
mode: 0666
|
||||
- name: acme
|
||||
configMap:
|
||||
name: acme-challenges
|
||||
@ -132,9 +117,32 @@ spec:
|
||||
name: uwsgi-config
|
||||
defaultMode: 0666
|
||||
items:
|
||||
- key: uwsgi.ini
|
||||
path: uwsgi.ini
|
||||
mode: 0644
|
||||
- key: uwsgi.ini
|
||||
path: uwsgi.ini
|
||||
mode: 0644
|
||||
- name: snippets
|
||||
configMap:
|
||||
name: nginx-snippets
|
||||
- name: nginx-secret
|
||||
flexVolume:
|
||||
driver: "azure/kv"
|
||||
options:
|
||||
usepodidentity: "true"
|
||||
keyvaultname: "atat-vault-test"
|
||||
keyvaultobjectnames: "dhparam4096;master-cert;master-cert"
|
||||
keyvaultobjectaliases: "dhparam.pem;atat.key;atat.crt"
|
||||
keyvaultobjecttypes: "secret;secret;secret"
|
||||
tenantid: $TENANT_ID
|
||||
- name: flask-secret
|
||||
flexVolume:
|
||||
driver: "azure/kv"
|
||||
options:
|
||||
usepodidentity: "true"
|
||||
keyvaultname: "atat-vault-test"
|
||||
keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY"
|
||||
keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY"
|
||||
keyvaultobjecttypes: "secret;secret;secret;secret;key"
|
||||
tenantid: $TENANT_ID
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
@ -155,47 +163,51 @@ spec:
|
||||
labels:
|
||||
app: atst
|
||||
role: worker
|
||||
aadpodidbinding: atat-kv-id-binding
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 101
|
||||
containers:
|
||||
- name: atst-worker
|
||||
image: $CONTAINER_IMAGE
|
||||
args: [
|
||||
"/opt/atat/atst/.venv/bin/python",
|
||||
"/opt/atat/atst/.venv/bin/celery",
|
||||
"-A",
|
||||
"celery_worker.celery",
|
||||
"worker",
|
||||
"--loglevel=info"
|
||||
]
|
||||
args:
|
||||
[
|
||||
"/opt/atat/atst/.venv/bin/python",
|
||||
"/opt/atat/atst/.venv/bin/celery",
|
||||
"-A",
|
||||
"celery_worker.celery",
|
||||
"worker",
|
||||
"--loglevel=info",
|
||||
]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: atst-envvars
|
||||
- configMapRef:
|
||||
name: atst-worker-envvars
|
||||
- configMapRef:
|
||||
name: atst-envvars
|
||||
- configMapRef:
|
||||
name: atst-worker-envvars
|
||||
volumeMounts:
|
||||
- name: atst-config
|
||||
mountPath: "/opt/atat/atst/atst-overrides.ini"
|
||||
subPath: atst-overrides.ini
|
||||
- name: pgsslrootcert
|
||||
mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt"
|
||||
subPath: pgsslrootcert.crt
|
||||
- name: flask-secret
|
||||
mountPath: "/config"
|
||||
volumes:
|
||||
- name: atst-config
|
||||
secret:
|
||||
secretName: atst-config-ini
|
||||
items:
|
||||
- key: override.ini
|
||||
path: atst-overrides.ini
|
||||
mode: 0644
|
||||
- name: pgsslrootcert
|
||||
configMap:
|
||||
name: pgsslrootcert
|
||||
items:
|
||||
- key: cert
|
||||
path: pgsslrootcert.crt
|
||||
mode: 0666
|
||||
- key: cert
|
||||
path: pgsslrootcert.crt
|
||||
mode: 0666
|
||||
- name: flask-secret
|
||||
flexVolume:
|
||||
driver: "azure/kv"
|
||||
options:
|
||||
usepodidentity: "true"
|
||||
keyvaultname: "atat-vault-test"
|
||||
keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY"
|
||||
keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY"
|
||||
keyvaultobjecttypes: "secret;secret;secret;secret;key"
|
||||
tenantid: $TENANT_ID
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Deployment
|
||||
@ -216,47 +228,51 @@ spec:
|
||||
labels:
|
||||
app: atst
|
||||
role: beat
|
||||
aadpodidbinding: atat-kv-id-binding
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 101
|
||||
containers:
|
||||
- name: atst-beat
|
||||
image: $CONTAINER_IMAGE
|
||||
args: [
|
||||
"/opt/atat/atst/.venv/bin/python",
|
||||
"/opt/atat/atst/.venv/bin/celery",
|
||||
"-A",
|
||||
"celery_worker.celery",
|
||||
"beat",
|
||||
"--loglevel=info"
|
||||
]
|
||||
args:
|
||||
[
|
||||
"/opt/atat/atst/.venv/bin/python",
|
||||
"/opt/atat/atst/.venv/bin/celery",
|
||||
"-A",
|
||||
"celery_worker.celery",
|
||||
"beat",
|
||||
"--loglevel=info",
|
||||
]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: atst-envvars
|
||||
- configMapRef:
|
||||
name: atst-worker-envvars
|
||||
- configMapRef:
|
||||
name: atst-envvars
|
||||
- configMapRef:
|
||||
name: atst-worker-envvars
|
||||
volumeMounts:
|
||||
- name: atst-config
|
||||
mountPath: "/opt/atat/atst/atst-overrides.ini"
|
||||
subPath: atst-overrides.ini
|
||||
- name: pgsslrootcert
|
||||
mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt"
|
||||
subPath: pgsslrootcert.crt
|
||||
- name: flask-secret
|
||||
mountPath: "/config"
|
||||
volumes:
|
||||
- name: atst-config
|
||||
secret:
|
||||
secretName: atst-config-ini
|
||||
items:
|
||||
- key: override.ini
|
||||
path: atst-overrides.ini
|
||||
mode: 0644
|
||||
- name: pgsslrootcert
|
||||
configMap:
|
||||
name: pgsslrootcert
|
||||
items:
|
||||
- key: cert
|
||||
path: pgsslrootcert.crt
|
||||
mode: 0666
|
||||
- key: cert
|
||||
path: pgsslrootcert.crt
|
||||
mode: 0666
|
||||
- name: flask-secret
|
||||
flexVolume:
|
||||
driver: "azure/kv"
|
||||
options:
|
||||
usepodidentity: "true"
|
||||
keyvaultname: "atat-vault-test"
|
||||
keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY"
|
||||
keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY"
|
||||
keyvaultobjecttypes: "secret;secret;secret;secret;key"
|
||||
tenantid: $TENANT_ID
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
@ -268,12 +284,12 @@ metadata:
|
||||
spec:
|
||||
loadBalancerIP: 13.92.235.6
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8342
|
||||
name: http
|
||||
- port: 443
|
||||
targetPort: 8442
|
||||
name: https
|
||||
- port: 80
|
||||
targetPort: 8342
|
||||
name: http
|
||||
- port: 443
|
||||
targetPort: 8442
|
||||
name: https
|
||||
selector:
|
||||
role: web
|
||||
type: LoadBalancer
|
||||
@ -288,12 +304,12 @@ metadata:
|
||||
spec:
|
||||
loadBalancerIP: 23.100.24.41
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8343
|
||||
name: http
|
||||
- port: 443
|
||||
targetPort: 8443
|
||||
name: https
|
||||
- port: 80
|
||||
targetPort: 8343
|
||||
name: http
|
||||
- port: 443
|
||||
targetPort: 8443
|
||||
name: https
|
||||
selector:
|
||||
role: web
|
||||
type: LoadBalancer
|
||||
|
@ -5,9 +5,16 @@ metadata:
|
||||
namespace: atat
|
||||
spec:
|
||||
schedule: "0 * * * *"
|
||||
concurrencyPolicy: Replace
|
||||
successfulJobsHistoryLimit: 1
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
role: crl-sync
|
||||
aadpodidbinding: atat-kv-id-binding
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
@ -25,19 +32,21 @@ spec:
|
||||
- configMapRef:
|
||||
name: atst-worker-envvars
|
||||
volumeMounts:
|
||||
- name: atst-config
|
||||
mountPath: "/opt/atat/atst/atst-overrides.ini"
|
||||
subPath: atst-overrides.ini
|
||||
- name: crls-vol
|
||||
mountPath: "/opt/atat/atst/crls"
|
||||
- name: flask-secret
|
||||
mountPath: "/config"
|
||||
volumes:
|
||||
- name: atst-config
|
||||
secret:
|
||||
secretName: atst-config-ini
|
||||
items:
|
||||
- key: override.ini
|
||||
path: atst-overrides.ini
|
||||
mode: 0644
|
||||
- name: crls-vol
|
||||
persistentVolumeClaim:
|
||||
claimName: crls-vol-claim
|
||||
- name: flask-secret
|
||||
flexVolume:
|
||||
driver: "azure/kv"
|
||||
options:
|
||||
usepodidentity: "true"
|
||||
keyvaultname: "atat-vault-test"
|
||||
keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY"
|
||||
keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY"
|
||||
keyvaultobjecttypes: "secret;secret;secret;secret;key"
|
||||
tenantid: $TENANT_ID
|
||||
|
@ -11,3 +11,4 @@ resources:
|
||||
- nginx-client-ca-bundle.yml
|
||||
- acme-challenges.yml
|
||||
- aadpodidentity.yml
|
||||
- nginx-snippets.yml
|
||||
|
24
deploy/azure/nginx-snippets.yml
Normal file
24
deploy/azure/nginx-snippets.yml
Normal 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;
|
62
deploy/overlays/staging/flex_vol.yml
Normal file
62
deploy/overlays/staging/flex_vol.yml
Normal 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"
|
@ -7,6 +7,7 @@ patchesStrategicMerge:
|
||||
- replica_count.yml
|
||||
- ports.yml
|
||||
- envvars.yml
|
||||
- flex_vol.yml
|
||||
patchesJson6902:
|
||||
- target:
|
||||
group: extensions
|
||||
|
@ -7,6 +7,11 @@ spec:
|
||||
ttlSecondsAfterFinished: 100
|
||||
backoffLimit: 2
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: atst
|
||||
role: migration
|
||||
aadpodidbinding: atat-kv-id-binding
|
||||
spec:
|
||||
containers:
|
||||
- name: migration
|
||||
@ -28,20 +33,12 @@ spec:
|
||||
- configMapRef:
|
||||
name: atst-worker-envvars
|
||||
volumeMounts:
|
||||
- name: atst-config
|
||||
mountPath: "/opt/atat/atst/atst-overrides.ini"
|
||||
subPath: atst-overrides.ini
|
||||
- name: pgsslrootcert
|
||||
mountPath: "/opt/atat/atst/ssl/pgsslrootcert.crt"
|
||||
subPath: pgsslrootcert.crt
|
||||
- name: flask-secret
|
||||
mountPath: "/config"
|
||||
volumes:
|
||||
- name: atst-config
|
||||
secret:
|
||||
secretName: atst-config-ini
|
||||
items:
|
||||
- key: override.ini
|
||||
path: atst-overrides.ini
|
||||
mode: 0644
|
||||
- name: pgsslrootcert
|
||||
configMap:
|
||||
name: pgsslrootcert
|
||||
@ -49,4 +46,14 @@ spec:
|
||||
- key: cert
|
||||
path: pgsslrootcert.crt
|
||||
mode: 0666
|
||||
- name: flask-secret
|
||||
flexVolume:
|
||||
driver: "azure/kv"
|
||||
options:
|
||||
usepodidentity: "true"
|
||||
keyvaultname: "atat-vault-test"
|
||||
keyvaultobjectnames: "master-AZURE-STORAGE-KEY;master-MAIL-PASSWORD;master-PGPASSWORD;master-REDIS-PASSWORD;master-SECRET-KEY"
|
||||
keyvaultobjectaliases: "AZURE_STORAGE_KEY;MAIL_PASSWORD;PGPASSWORD;REDIS_PASSWORD;SECRET_KEY"
|
||||
keyvaultobjecttypes: "secret;secret;secret;secret;key"
|
||||
tenantid: $TENANT_ID
|
||||
restartPolicy: Never
|
||||
|
390
fixtures/fixture_spend_data.json
Normal file
390
fixtures/fixture_spend_data.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
102
js/components/__tests__/multi_checkbox_input.test.js
Normal file
102
js/components/__tests__/multi_checkbox_input.test.js
Normal 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)
|
||||
})
|
||||
})
|
@ -11,4 +11,10 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
collapse: function() {
|
||||
this.isVisible = false
|
||||
},
|
||||
},
|
||||
}
|
||||
|
16
js/components/accordion_list.js
Normal file
16
js/components/accordion_list.js
Normal 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())
|
||||
},
|
||||
},
|
||||
}
|
@ -13,22 +13,14 @@ export default {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
initialOtherValue: String,
|
||||
optional: Boolean,
|
||||
},
|
||||
|
||||
data: function() {
|
||||
const showError = (this.initialErrors && this.initialErrors.length) || false
|
||||
return {
|
||||
showError: showError,
|
||||
showValid: !showError && this.initialValue.length > 0,
|
||||
showError: this.initialErrors.length > 0,
|
||||
showValid: false,
|
||||
validationError: this.initialErrors.join(' '),
|
||||
otherChecked: this.initialValue.includes('other')
|
||||
? true
|
||||
: this.otherChecked,
|
||||
otherText: this.initialValue.includes('other')
|
||||
? this.initialOtherValue
|
||||
: '',
|
||||
selections: this.initialValue,
|
||||
}
|
||||
},
|
||||
@ -36,17 +28,15 @@ export default {
|
||||
methods: {
|
||||
onInput: function(e) {
|
||||
emitFieldChange(this)
|
||||
this.showError = false
|
||||
this.showValid = true
|
||||
},
|
||||
otherToggle: function() {
|
||||
this.otherChecked = !this.otherChecked
|
||||
this.showError = !this.valid
|
||||
this.showValid = !this.showError
|
||||
this.validationError = 'This field is required.'
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
valid: function() {
|
||||
return this.optional || this.showValid
|
||||
return this.optional || this.selections.length > 0
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -1,14 +1,12 @@
|
||||
import { set } from 'vue/dist/vue'
|
||||
import { formatDollars } from '../../lib/dollars'
|
||||
import { set as _set } from 'lodash'
|
||||
|
||||
export default {
|
||||
name: 'spend-table',
|
||||
|
||||
props: {
|
||||
applications: Object,
|
||||
environments: Object,
|
||||
currentMonthIndex: String,
|
||||
prevMonthIndex: String,
|
||||
applications: Array,
|
||||
},
|
||||
|
||||
data: function() {
|
||||
@ -18,20 +16,16 @@ export default {
|
||||
},
|
||||
|
||||
created: function() {
|
||||
Object.keys(this.applications).forEach(application => {
|
||||
set(this.applicationsState[application], 'isVisible', false)
|
||||
this.applicationsState.forEach(application => {
|
||||
application.isVisible = false
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggle: function(e, applicationName) {
|
||||
this.applicationsState = Object.assign(this.applicationsState, {
|
||||
[applicationName]: Object.assign(
|
||||
this.applicationsState[applicationName],
|
||||
{
|
||||
isVisible: !this.applicationsState[applicationName].isVisible,
|
||||
}
|
||||
),
|
||||
toggle: function(e, applicationIndex) {
|
||||
set(this.applicationsState, applicationIndex, {
|
||||
...this.applicationsState[applicationIndex],
|
||||
isVisible: !this.applicationsState[applicationIndex].isVisible,
|
||||
})
|
||||
},
|
||||
|
||||
|
@ -58,18 +58,18 @@ export default {
|
||||
this.$refs.attachmentFilename.value = file.name
|
||||
this.$refs.attachmentObjectName.value = response.objectName
|
||||
this.$refs.attachmentInput.disabled = true
|
||||
emitFieldChange(this)
|
||||
this.changed = true
|
||||
|
||||
this.downloadLink = await this.getDownloadLink(
|
||||
file.name,
|
||||
response.objectName
|
||||
)
|
||||
} else {
|
||||
emitFieldChange(this)
|
||||
this.changed = true
|
||||
this.uploadError = true
|
||||
}
|
||||
|
||||
this.changed = true
|
||||
|
||||
emitFieldChange(this)
|
||||
},
|
||||
removeAttachment: function(e) {
|
||||
e.preventDefault()
|
||||
|
@ -7,6 +7,8 @@ import Vue from 'vue/dist/vue'
|
||||
import VTooltip from 'v-tooltip'
|
||||
import stickybits from 'stickybits'
|
||||
|
||||
import Accordion from './components/accordion'
|
||||
import AccordionList from './components/accordion_list'
|
||||
import dodlogin from './components/dodlogin'
|
||||
import optionsinput from './components/options_input'
|
||||
import multicheckboxinput from './components/multi_checkbox_input'
|
||||
@ -29,7 +31,6 @@ import SemiCollapsibleText from './components/semi_collapsible_text'
|
||||
import ToForm from './components/forms/to_form'
|
||||
import ClinFields from './components/clin_fields'
|
||||
import PopDateRange from './components/pop_date_range'
|
||||
import Accordion from './components/accordion'
|
||||
import ToggleMenu from './components/toggle_menu'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
@ -42,6 +43,7 @@ const app = new Vue({
|
||||
el: '#app-root',
|
||||
components: {
|
||||
Accordion,
|
||||
AccordionList,
|
||||
dodlogin,
|
||||
toggler,
|
||||
optionsinput,
|
||||
|
@ -17,6 +17,7 @@ export default {
|
||||
methods: {
|
||||
toggle: function(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
this.isVisible = !this.isVisible
|
||||
},
|
||||
},
|
||||
|
@ -15,7 +15,7 @@ PASSWORD = os.getenv("ATAT_BA_PASSWORD", "")
|
||||
DISABLE_VERIFY = os.getenv("DISABLE_VERIFY", "true").lower() == "true"
|
||||
|
||||
# Alpha numerics for random entity names
|
||||
LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890"
|
||||
LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" #pragma: allowlist secret
|
||||
|
||||
NEW_PORTFOLIO_CHANCE = 10
|
||||
NEW_APPLICATION_CHANCE = 10
|
||||
@ -141,15 +141,8 @@ def create_portfolio(l):
|
||||
new_portfolio_form = l.client.get("/portfolios/new")
|
||||
new_portfolio_body = {
|
||||
"name": f"Load Test Created - {''.join(choices(LETTERS, k=5))}",
|
||||
"defense_component": "Army, Department of the",
|
||||
"defense_component": "army",
|
||||
"description": "Test",
|
||||
"app_migration": "none",
|
||||
"native_apps": "yes",
|
||||
"complexity": "storage",
|
||||
"complexity_other": "",
|
||||
"dev_team": "civilians",
|
||||
"dev_team_other": "",
|
||||
"team_experience": "none",
|
||||
"csrf_token": get_csrf_token(new_portfolio_form),
|
||||
}
|
||||
|
||||
|
94
script/integration_tests
Executable file
94
script/integration_tests
Executable 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
|
@ -13,6 +13,7 @@ SETTINGS=(
|
||||
AUTH_DOMAIN
|
||||
KV_MI_ID
|
||||
KV_MI_CLIENT_ID
|
||||
TENANT_ID
|
||||
)
|
||||
|
||||
# Loop all expected settings. Track ones that are missing and build
|
||||
|
@ -14,6 +14,7 @@ from atst.app import make_config, make_app
|
||||
from atst.database import db
|
||||
|
||||
from atst.models.application import Application
|
||||
from atst.models.clin import JEDICLINType
|
||||
from atst.models.environment_role import CSPRole
|
||||
|
||||
from atst.domain.application_roles import ApplicationRoles
|
||||
@ -30,6 +31,8 @@ from atst.domain.users import Users
|
||||
|
||||
from atst.routes.dev import _DEV_USERS as DEV_USERS
|
||||
|
||||
from atst.utils import pick
|
||||
|
||||
from tests.factories import (
|
||||
random_service_branch,
|
||||
TaskOrderFactory,
|
||||
@ -197,7 +200,22 @@ def add_task_orders_to_portfolio(portfolio):
|
||||
CLINFactory.build(
|
||||
task_order=expired_to, start_date=(today - five_days), end_date=yesterday
|
||||
),
|
||||
CLINFactory.build(task_order=active_to, start_date=yesterday, end_date=future),
|
||||
CLINFactory.build(
|
||||
task_order=active_to,
|
||||
start_date=yesterday,
|
||||
end_date=future,
|
||||
total_amount=1_000_000,
|
||||
obligated_amount=500_000,
|
||||
jedi_clin_type=JEDICLINType.JEDI_CLIN_1,
|
||||
),
|
||||
CLINFactory.build(
|
||||
task_order=active_to,
|
||||
start_date=yesterday,
|
||||
end_date=future,
|
||||
total_amount=500_000,
|
||||
obligated_amount=200_000,
|
||||
jedi_clin_type=JEDICLINType.JEDI_CLIN_2,
|
||||
),
|
||||
]
|
||||
|
||||
task_orders = [draft_to, unsigned_to, upcoming_to, expired_to, active_to]
|
||||
@ -238,6 +256,7 @@ def add_applications_to_portfolio(portfolio):
|
||||
None,
|
||||
first_name=user_data["first_name"],
|
||||
last_name=user_data["last_name"],
|
||||
email=user_data["email"],
|
||||
)
|
||||
|
||||
app_role = ApplicationRoles.create(
|
||||
@ -263,7 +282,23 @@ def add_applications_to_portfolio(portfolio):
|
||||
|
||||
def create_demo_portfolio(name, data):
|
||||
try:
|
||||
portfolio_owner = Users.get_or_create_by_dod_id("2345678901") # Amanda
|
||||
portfolio_owner = Users.get_or_create_by_dod_id(
|
||||
"2345678901",
|
||||
**pick(
|
||||
[
|
||||
"permission_sets",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"service_branch",
|
||||
"phone_number",
|
||||
"citizenship",
|
||||
"designation",
|
||||
"date_latest_training",
|
||||
],
|
||||
DEV_USERS["amanda"],
|
||||
),
|
||||
) # Amanda
|
||||
# auditor = Users.get_by_dod_id("3453453453") # Sally
|
||||
except NotFoundError:
|
||||
print(
|
||||
@ -281,9 +316,9 @@ def create_demo_portfolio(name, data):
|
||||
|
||||
for mock_application in data["applications"]:
|
||||
application = Application(
|
||||
portfolio=portfolio, name=mock_application.name, description=""
|
||||
portfolio=portfolio, name=mock_application["name"], description=""
|
||||
)
|
||||
env_names = [env.name for env in mock_application.environments]
|
||||
env_names = [env["name"] for env in mock_application["environments"]]
|
||||
envs = Environments.create_many(portfolio.owner, application, env_names)
|
||||
db.session.add(application)
|
||||
db.session.commit()
|
||||
@ -294,8 +329,8 @@ def seed_db():
|
||||
amanda = Users.get_by_dod_id("2345678901")
|
||||
|
||||
# Create Portfolios for Amanda with mocked reporting data
|
||||
create_demo_portfolio("A-Wing", MockReportingProvider.REPORT_FIXTURE_MAP["A-Wing"])
|
||||
create_demo_portfolio("B-Wing", MockReportingProvider.REPORT_FIXTURE_MAP["B-Wing"])
|
||||
create_demo_portfolio("A-Wing", MockReportingProvider.FIXTURE_SPEND_DATA["A-Wing"])
|
||||
create_demo_portfolio("B-Wing", MockReportingProvider.FIXTURE_SPEND_DATA["B-Wing"])
|
||||
|
||||
tie_interceptor = Portfolios.create(
|
||||
user=amanda,
|
||||
|
@ -45,11 +45,3 @@
|
||||
@import "sections/application_edit";
|
||||
@import "sections/reports";
|
||||
@import "sections/task_order";
|
||||
|
||||
//
|
||||
// IE likes to display an outline when focusing on an element. This
|
||||
// fix removes that unwanted outline on focus.
|
||||
//
|
||||
*:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
@ -1,28 +1,20 @@
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 5rem ($gap * 2) 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: $gap * 3;
|
||||
max-width: 100%;
|
||||
background-color: $color-gray-lightest;
|
||||
margin-top: $gap * 5;
|
||||
|
||||
> .icon {
|
||||
@include icon-size(50);
|
||||
@include icon-color($color-gray-light);
|
||||
hr {
|
||||
margin-left: -$gap * 3;
|
||||
margin-right: -$gap * 3;
|
||||
}
|
||||
|
||||
&__message {
|
||||
font-weight: $font-bold;
|
||||
}
|
||||
&__footer {
|
||||
text-align: center;
|
||||
|
||||
&__sub-message {
|
||||
@include h4;
|
||||
|
||||
color: $color-gray;
|
||||
max-width: 100%;
|
||||
|
||||
@include media($large-screen) {
|
||||
@include h3;
|
||||
a.usa-button {
|
||||
width: 60%;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
// Form Grid
|
||||
.form-row {
|
||||
margin: ($gap * 4) 0;
|
||||
&--separated {
|
||||
border-bottom: $color-gray-lighter 1px solid;
|
||||
}
|
||||
|
||||
.form-col {
|
||||
flex-grow: 1;
|
||||
|
@ -383,6 +383,8 @@
|
||||
}
|
||||
|
||||
.portfolio-applications {
|
||||
margin-top: $gap * 5;
|
||||
|
||||
&__header {
|
||||
&--title {
|
||||
@include subheading;
|
||||
|
@ -1,7 +1,7 @@
|
||||
.sticky-cta {
|
||||
margin-left: -$gap * 4;
|
||||
margin-left: -$gap * 5;
|
||||
margin-right: -$gap * 5;
|
||||
z-index: 10;
|
||||
z-index: 1;
|
||||
background-color: $color-gray-lightest;
|
||||
border-top: 1px solid $color-gray-lighter;
|
||||
border-bottom: 1px solid $color-gray-lighter;
|
||||
|
@ -40,8 +40,7 @@
|
||||
}
|
||||
|
||||
&.col--grow {
|
||||
flex: 1;
|
||||
flex-grow: 1;
|
||||
flex: 1 auto;
|
||||
padding-right: $spacing-small;
|
||||
}
|
||||
|
||||
|
@ -73,6 +73,10 @@
|
||||
color: $color-green;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: $color-secondary;
|
||||
}
|
||||
|
||||
.user-permission {
|
||||
font-weight: $font-normal;
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ $footer-height: 5rem;
|
||||
$usa-banner-height: 2.8rem;
|
||||
$sidenav-expanded-width: 25rem;
|
||||
$sidenav-collapsed-width: 10rem;
|
||||
$max-panel-width: 80rem;
|
||||
|
||||
/*
|
||||
* USWDS Variables
|
||||
|
@ -1,148 +1,65 @@
|
||||
.triangle-box {
|
||||
position: relative;
|
||||
|
||||
.triangle-up {
|
||||
$triangle-size: $gap * 1.5;
|
||||
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: $triangle-size solid transparent;
|
||||
border-right: $triangle-size solid transparent;
|
||||
border-bottom: $triangle-size solid $color-blue-light;
|
||||
bottom: -4px;
|
||||
right: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion {
|
||||
@include block-list;
|
||||
|
||||
box-shadow: 0 4px 10px 0 rgba(193, 193, 193, 0.5);
|
||||
margin-bottom: 6 * $gap;
|
||||
|
||||
.icon-link {
|
||||
margin: (-$gap) 0;
|
||||
}
|
||||
|
||||
.icon-link,
|
||||
.label {
|
||||
&:first-child {
|
||||
margin-left: -$gap;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: -$gap;
|
||||
}
|
||||
}
|
||||
@include shadow-panel;
|
||||
margin: $gap * 3 0;
|
||||
max-width: $max-panel-width;
|
||||
|
||||
&__header {
|
||||
@include block-list-header;
|
||||
padding: $gap * 2 $gap * 3;
|
||||
background-color: $color-white;
|
||||
|
||||
border-top: 3px solid $color-blue;
|
||||
border-bottom: none;
|
||||
box-shadow: 0 2px 4px 0 rgba(216, 218, 222, 0.58);
|
||||
|
||||
&.row {
|
||||
background: $color-white;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include block-list__title;
|
||||
|
||||
color: $color-blue;
|
||||
|
||||
@include h3;
|
||||
|
||||
&.icon-link {
|
||||
&-text {
|
||||
margin: 0;
|
||||
display: block;
|
||||
padding: 0 $gap;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
@include block-list__description;
|
||||
|
||||
font-style: italic;
|
||||
font-size: $small-font-size;
|
||||
color: $color-gray;
|
||||
&__button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-top: $gap;
|
||||
margin-bottom: $gap * 0.5;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
&__content {
|
||||
padding: 0 ($gap * 3) $gap;
|
||||
|
||||
.icon-link {
|
||||
font-size: $small-font-size;
|
||||
&--list-item {
|
||||
border-bottom: 1px solid $color-gray-lightest;
|
||||
padding: $gap 0;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: $gap;
|
||||
}
|
||||
|
||||
.col {
|
||||
padding-right: $gap * 2;
|
||||
|
||||
&:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: $gap * 2 0 $gap;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
color: $color-gray;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
@include block-list__footer;
|
||||
|
||||
border-top: 0;
|
||||
&--empty {
|
||||
font-weight: $font-bold;
|
||||
color: $color-gray-dark;
|
||||
padding: $gap * 8;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
@include block-list-item;
|
||||
&-list {
|
||||
max-width: $max-panel-width;
|
||||
|
||||
opacity: 0.75;
|
||||
background-color: $color-blue-light;
|
||||
border-bottom: 1px solid rgba($color-gray-light, 0.5);
|
||||
|
||||
&--selectable {
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
@include ie-only {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> label {
|
||||
@include block-list-selectable-label;
|
||||
}
|
||||
}
|
||||
|
||||
> label {
|
||||
@include block-list-selectable-label;
|
||||
}
|
||||
|
||||
input:checked {
|
||||
+ label {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
@include ie-only {
|
||||
dl {
|
||||
width: 100%;
|
||||
padding-left: $gap * 4;
|
||||
}
|
||||
}
|
||||
&__collapse {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.counter {
|
||||
background-color: $color-cool-blue-light;
|
||||
color: $color-white;
|
||||
border-radius: 2px;
|
||||
padding: ($gap / 2) $gap;
|
||||
margin-left: $gap;
|
||||
}
|
||||
|
||||
.separator {
|
||||
border: 1px solid $color-gray-medium;
|
||||
opacity: 0.75;
|
||||
margin: 0 (0.5 * $gap);
|
||||
}
|
||||
}
|
||||
|
@ -90,4 +90,8 @@
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&--primary {
|
||||
@include icon-color($color-primary);
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,16 @@
|
||||
}
|
||||
|
||||
.jedi-clin-funding {
|
||||
$insufficient-gradient: repeating-linear-gradient(
|
||||
45deg,
|
||||
$color-secondary-dark,
|
||||
$color-secondary-dark 10px,
|
||||
$color-secondary-darkest 11px,
|
||||
$color-secondary-darkest 14px
|
||||
);
|
||||
|
||||
$graph-bar-height: 2rem;
|
||||
|
||||
padding-top: $gap * 3;
|
||||
padding-bottom: $gap * 3;
|
||||
|
||||
@ -37,14 +47,36 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__meter {
|
||||
margin: 10px 0;
|
||||
-moz-transform: scale(-1, 1);
|
||||
-webkit-transform: scale(-1, 1);
|
||||
-o-transform: scale(-1, 1);
|
||||
-ms-transform: scale(-1, 1);
|
||||
transform: scale(-1, 1);
|
||||
&__graph {
|
||||
width: 100%;
|
||||
height: $graph-bar-height;
|
||||
margin-top: $gap * 2;
|
||||
margin-bottom: $gap * 2;
|
||||
display: flex;
|
||||
|
||||
&-bar {
|
||||
height: 100%;
|
||||
display: block;
|
||||
float: left;
|
||||
margin-right: $gap / 2;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&--invoiced {
|
||||
background: $color-green;
|
||||
}
|
||||
&--estimated {
|
||||
background: $color-green-lighter;
|
||||
}
|
||||
&--remaining {
|
||||
background: $color-primary-darkest;
|
||||
}
|
||||
&--insufficient {
|
||||
background: $insufficient-gradient;
|
||||
}
|
||||
}
|
||||
|
||||
&-values {
|
||||
display: flex;
|
||||
@ -52,13 +84,32 @@
|
||||
}
|
||||
|
||||
&__meta {
|
||||
&--remaining {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
margin-right: $gap * 5;
|
||||
|
||||
&-header {
|
||||
@include small-copy;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-key {
|
||||
height: $graph-bar-height;
|
||||
width: $graph-bar-height;
|
||||
margin-right: $gap / 2;
|
||||
|
||||
&--invoiced {
|
||||
background: $color-green;
|
||||
}
|
||||
&--estimated {
|
||||
background: $color-green-lighter;
|
||||
}
|
||||
&--remaining {
|
||||
background: $color-primary-darkest;
|
||||
}
|
||||
&--insufficient {
|
||||
background: $insufficient-gradient;
|
||||
}
|
||||
}
|
||||
&-value {
|
||||
margin-bottom: 0;
|
||||
@ -87,4 +138,32 @@
|
||||
font-size: $lead-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.reporting-expended-funding {
|
||||
&__header {
|
||||
margin: 0;
|
||||
}
|
||||
&__content {
|
||||
padding: 0;
|
||||
border-top: 1px solid $color-gray-lighter;
|
||||
}
|
||||
}
|
||||
|
||||
.reporting-spend-table {
|
||||
&__env-row {
|
||||
&-label {
|
||||
margin-left: $gap * 5;
|
||||
}
|
||||
&--last {
|
||||
& > td {
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
&:last-of-type {
|
||||
& > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,25 +1,3 @@
|
||||
.task-order-list {
|
||||
margin-top: 6 * $gap;
|
||||
}
|
||||
|
||||
.task-order-card {
|
||||
&__buttons .usa-button {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
&__buttons .usa-button-secondary {
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $small-font-size;
|
||||
margin-right: 2 * $gap;
|
||||
min-width: 7rem;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
}
|
||||
|
||||
.task-order {
|
||||
margin-top: $gap * 4;
|
||||
width: 900px;
|
||||
@ -149,21 +127,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label {
|
||||
&--pending,
|
||||
&--started {
|
||||
background-color: $color-gold;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: $color-green;
|
||||
}
|
||||
|
||||
&--expired {
|
||||
background-color: $color-red;
|
||||
}
|
||||
}
|
||||
|
||||
.task-order-document-link {
|
||||
&__icon {
|
||||
padding-top: 0.5rem;
|
||||
|
@ -1,5 +1,8 @@
|
||||
{% from "components/icon.html" import Icon %}
|
||||
{% from "components/accordion.html" import Accordion %}
|
||||
{% from "components/accordion_list.html" import AccordionList %}
|
||||
{% from "components/empty_state.html" import EmptyState %}
|
||||
{% from "components/sticky_cta.html" import StickyCTA %}
|
||||
{% from "components/icon.html" import Icon %}
|
||||
|
||||
{% extends "portfolios/base.html" %}
|
||||
|
||||
@ -7,85 +10,74 @@
|
||||
|
||||
{% block portfolio_content %}
|
||||
|
||||
{% call StickyCTA(text="common.applications"|translate) %}
|
||||
{% if can_create_applications and portfolio.applications %}
|
||||
<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'>
|
||||
{% 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 %}
|
||||
|
||||
{{ EmptyState(
|
||||
'This portfolio doesn’t have any applications',
|
||||
action_label='Add a new application' if can_create_applications else None,
|
||||
action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None,
|
||||
icon='cloud',
|
||||
sub_message=None if can_create_applications else 'Please contact your JEDI Cloud portfolio administrator to set up a new application.',
|
||||
add_perms=can_create_applications
|
||||
header="portfolios.applications.empty_state.header"|translate,
|
||||
message="portfolios.applications.empty_state.message"|translate,
|
||||
button_text="portfolios.applications.empty_state.button_text"|translate,
|
||||
button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id),
|
||||
view_only_text="portfolios.applications.empty_state.view_only_text"|translate,
|
||||
user_can_create=can_create_applications,
|
||||
) }}
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class='application-list'>
|
||||
{% call AccordionList() %}
|
||||
{% for application in portfolio.applications|sort(attribute='name') %}
|
||||
{% set section_name = "application-{}".format(application.id) %}
|
||||
|
||||
<toggler inline-template>
|
||||
<div class='accordion application-list-item'>
|
||||
<header class='accordion__header row'>
|
||||
<div class='col col-grow'>
|
||||
<h3 class='icon-link accordion__title' v-on:click="toggleSection('{{ section_name }}')">{{ application.name }}</h3>
|
||||
<p class='accordion__description'>
|
||||
{{ application.description }}
|
||||
</p>
|
||||
<div class='accordion__actions'>
|
||||
<a class='icon-link' href='{{ url_for("applications.settings", application_id=application.id) }}'>
|
||||
<span>{{ "portfolios.applications.app_settings_text" | translate }}</span>
|
||||
</a>
|
||||
<div class='separator'></div>
|
||||
{% set has_environments = 0 < (application.environments|length) %}
|
||||
<a class='icon-link triangle-box' v-on:click="toggleSection('{{ section_name }}')" disabled="{{ not has_environments }}">
|
||||
<span>Environments ({{ application.environments|length }})</span>
|
||||
{% if has_environments %}
|
||||
<span v-if="selectedSection === '{{ section_name }}'">
|
||||
{{ Icon('caret_up') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ Icon('caret_down') }}
|
||||
</span>
|
||||
<div class="triangle-up" v-if="selectedSection === '{{ section_name }}'"></div>
|
||||
{% set title = "Environments ({})".format(application.environments|length) %}
|
||||
<div class="accordion">
|
||||
<div class="accordion__header">
|
||||
<h3 class="accordion__header-text">
|
||||
<a href='{{ url_for("applications.settings", application_id=application.id) }}'>
|
||||
{{ application.name }} {{ Icon("caret_right", classes="icon--tiny icon--primary") }}
|
||||
</a>
|
||||
</h3>
|
||||
<p class="accordion__header-text">
|
||||
{{ application.description }}
|
||||
</p>
|
||||
</div>
|
||||
{% call Accordion(
|
||||
title=title,
|
||||
id=section_name,
|
||||
heading_tag="h4"
|
||||
) %}
|
||||
{% for environment in application.environments %}
|
||||
{% set env_access = environment_access[environment.id] %}
|
||||
<div class="accordion__content--list-item">
|
||||
<div class="row">
|
||||
<div class="col col--grow">
|
||||
{% if env_access %}
|
||||
<a href='{{ url_for("applications.access_environment", environment_id=environment.id)}}' target='_blank' rel='noopener noreferrer'>
|
||||
{{ environment.displayname }} {{ Icon('link', classes='icon--medium icon--primary') }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ environment.displayname }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% if env_access %}
|
||||
<div class="col">
|
||||
{{ env_access }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<ul v-show="selectedSection === '{{ section_name }}'">
|
||||
{% for environment in application.environments %}
|
||||
<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 %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
@ -1,23 +1,30 @@
|
||||
{% macro Accordion(title, id, heading_level="h2") %}
|
||||
<accordion inline-template>
|
||||
<div>
|
||||
<{{heading_level}}>
|
||||
<button
|
||||
v-on:click="toggle($event)"
|
||||
class="usa-accordion-button"
|
||||
aria-controls="{{ id }}"
|
||||
v-bind:aria-expanded= "isVisible ? 'true' : 'false'"
|
||||
>
|
||||
{{ title }}
|
||||
</button>
|
||||
</{{heading_level}}>
|
||||
<div
|
||||
id="{{ id }}"
|
||||
class="usa-accordion-content"
|
||||
v-bind:aria-hidden="isVisible ? 'false' : 'true'"
|
||||
>
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
</accordion>
|
||||
{% endmacro %}
|
||||
{% macro Accordion(
|
||||
title,
|
||||
id,
|
||||
wrapper_tag="div",
|
||||
wrapper_classes="",
|
||||
heading_tag="h2",
|
||||
heading_classes="",
|
||||
content_tag="div",
|
||||
content_classes="") %}
|
||||
<accordion v-cloak inline-template>
|
||||
<{{wrapper_tag}} class="{{ wrapper_classes }}">
|
||||
<{{heading_tag}} class="accordion__button {{ heading_classes }}">
|
||||
<button
|
||||
v-on:click="toggle($event)"
|
||||
class="usa-accordion-button"
|
||||
aria-controls="{{ id }}"
|
||||
v-bind:aria-expanded= "isVisible ? 'true' : 'false'"
|
||||
>
|
||||
{{ title }}
|
||||
</button>
|
||||
</{{heading_tag}}>
|
||||
<{{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 %}
|
||||
|
11
templates/components/accordion_list.html
Normal file
11
templates/components/accordion_list.html
Normal 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 %}
|
@ -1,20 +1,14 @@
|
||||
{% from "components/icon.html" import Icon %}
|
||||
|
||||
{% macro EmptyState(message, action_label, action_href, icon=None, sub_message=None, add_perms=True) -%}
|
||||
<div class='empty-state'>
|
||||
<p class='empty-state__message'>{{ message }}</p>
|
||||
|
||||
{% if icon %}
|
||||
{{ Icon(icon) }}
|
||||
{% endif %}
|
||||
|
||||
{% if sub_message %}
|
||||
<p class='empty-state__sub-message'>{{ sub_message }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if add_perms and (action_href and action_label) %}
|
||||
<a href='{{ action_href }}' class='usa-button usa-button-big'>{{ action_label }}</a>
|
||||
{% endif %}
|
||||
|
||||
{% macro EmptyState(header, message, button_text, button_link, view_only_text, user_can_create=True) %}
|
||||
<div class="empty-state">
|
||||
<h3>{{ header }}</h3>
|
||||
<p>{{ message }}</p>
|
||||
<hr>
|
||||
<div class="empty-state__footer">
|
||||
{% if user_can_create %}
|
||||
<a href="{{ button_link }}" class="usa-button usa-button-primary">{{ button_text }}</a>
|
||||
{% else %}
|
||||
<p>{{ view_only_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
{% endmacro %}
|
||||
|
@ -45,19 +45,8 @@
|
||||
<ul>
|
||||
{% for choice in field.choices %}
|
||||
<li>
|
||||
{% if choice[0] != 'other' %}
|
||||
<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>
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@ -14,7 +14,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% 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 }}
|
||||
</a>
|
||||
{% endcall %}
|
||||
|
@ -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 %}
|
@ -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 %}
|
||||
|
52
templates/portfolios/new/step_1.html
Normal file
52
templates/portfolios/new/step_1.html
Normal 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 %}
|
||||
|
@ -3,9 +3,6 @@
|
||||
|
||||
<div>
|
||||
<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 %}
|
||||
|
||||
{% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %}
|
||||
@ -15,20 +12,16 @@
|
||||
%}
|
||||
|
||||
{{ EmptyState(
|
||||
('portfolios.reports.empty_state.message' | translate),
|
||||
action_label= ('portfolios.reports.empty_state.action_label' | translate) if can_create_applications else None,
|
||||
action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None,
|
||||
icon='chart',
|
||||
sub_message=message,
|
||||
add_perms=can_create_applications
|
||||
header='portfolios.reports.empty_state.message' | translate,
|
||||
message=message,
|
||||
button_text="portfolios.applications.empty_state.button_text"|translate,
|
||||
button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id),
|
||||
view_only_text="portfolios.applications.empty_state.view_only_text"|translate,
|
||||
user_can_create=can_create_applications,
|
||||
) }}
|
||||
|
||||
{% else %}
|
||||
<spend-table
|
||||
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>
|
||||
<spend-table v-bind:applications='{{ monthly_spending | tojson }}' inline-template>
|
||||
<div class="responsive-table-wrapper">
|
||||
<table class="atat-table">
|
||||
<thead>
|
||||
@ -40,37 +33,41 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for='(application, name) in applicationsState'>
|
||||
<template v-for='(application, applicationIndex) in applicationsState'>
|
||||
<tr>
|
||||
<td>
|
||||
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large'>
|
||||
<span v-html='name'></span>
|
||||
<button v-on:click='toggle($event, applicationIndex)' class='icon-link icon-link--large'>
|
||||
<span v-html='application.name'></span>
|
||||
<template v-if='application.isVisible'>{{ Icon('caret_down') }}</template>
|
||||
<template v-else>{{ Icon('caret_up') }}</template>
|
||||
</button>
|
||||
</td>
|
||||
<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 class="table-cell--align-right">
|
||||
<span v-html='formatDollars(application[prevMonthIndex] || 0)'></span>
|
||||
<span v-html='formatDollars(application.last_month || 0)'></span>
|
||||
</td>
|
||||
<td class="table-cell--align-right">
|
||||
<span v-html='formatDollars(application["total_spend_to_date"])'></span>
|
||||
<span v-html='formatDollars(application.total)'></span>
|
||||
</td>
|
||||
</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>
|
||||
<span v-html='envName'></span>
|
||||
<span class="reporting-spend-table__env-row-label" v-html='environment.name'></span>
|
||||
</td>
|
||||
<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 class="table-cell--align-right">
|
||||
<span v-html='formatDollars(environment[prevMonthIndex] || 0)'></span>
|
||||
<span v-html='formatDollars(environment.last_month || 0)'></span>
|
||||
</td>
|
||||
<td class="table-cell--align-right">
|
||||
<span v-html='formatDollars(environment["total_spend_to_date"])'></span>
|
||||
<span v-html='formatDollars(environment.total)'></span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
@ -1,34 +1,51 @@
|
||||
{% from "components/accordion.html" import Accordion %}
|
||||
{% from "components/icon.html" import Icon %}
|
||||
|
||||
|
||||
<section>
|
||||
<div class="usa-accordion">
|
||||
{% call Accordion("Expired Task Orders", "expired_task_orders", "h3") %}
|
||||
{% for task_order in expired_task_orders %}
|
||||
<a href="{{ url_for("task_orders.review_task_order", task_order_id=task_order["id"]) }}">
|
||||
Task Order {{ task_order["number"] }}
|
||||
</a>
|
||||
<div>
|
||||
<p>Period of Performance</p>
|
||||
<p>
|
||||
{{ task_order["period_of_performance"].start_date | formattedDate(formatter="%B %d, %Y") }}
|
||||
-
|
||||
{{ task_order["period_of_performance"].end_date | formattedDate(formatter="%B %d, %Y") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Total Obligated</p>
|
||||
<p>{{ task_order["total_obligated_funds"] | dollars }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Total Expended</p>
|
||||
<p>{{ task_order["expended_funds"] | dollars }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Total Unused</p>
|
||||
<p>{{ (task_order["total_obligated_funds"] - task_order["expended_funds"]) | dollars }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% call Accordion(
|
||||
"Expired funding",
|
||||
"expired_funding",
|
||||
heading_classes="reporting-expended-funding__header",
|
||||
content_tag="table",
|
||||
content_classes="atat-table reporting-expended-funding__content") %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>TO CLIN</th>
|
||||
<th>PoP</th>
|
||||
<th>CLIN Value</th>
|
||||
<th>Amount Obligated</th>
|
||||
<th>Amount Unspent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task_order in expired_task_orders %}
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<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) }}">
|
||||
{{ task_order.number }} {{ Icon("caret_right", classes="icon--tiny icon--blue" ) }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% for clin in task_order.clins %}
|
||||
<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 %}
|
||||
</div>
|
||||
</section>
|
||||
|
@ -5,7 +5,9 @@
|
||||
|
||||
{% block portfolio_content %}
|
||||
{{ StickyCTA("Reports") }}
|
||||
|
||||
<div class="portfolio-reports col col--grow">
|
||||
{% include "fragments/flash.html" %}
|
||||
<p class="row estimate-warning">{{ "portfolios.reports.estimate_warning" | translate }}</p>
|
||||
{% include "portfolios/reports/portfolio_summary.html" %}
|
||||
<hr>
|
||||
|
@ -3,28 +3,61 @@
|
||||
<section>
|
||||
<header class="reporting-section-header">
|
||||
<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>
|
||||
<div class='panel'>
|
||||
<div class='panel__content jedi-clin-funding'>
|
||||
{% for JEDI_clin, funds in current_obligated_funds.items() %}
|
||||
{% set remaining_funds = (funds["obligated_funds"] - funds["expended_funds"]) %}
|
||||
{% for JEDI_clin in current_obligated_funds | sort(attribute='name')%}
|
||||
<div class="jedi-clin-funding__clin-wrapper">
|
||||
<h3 class="h5 jedi-clin-funding__header">
|
||||
{{ "JEDICLINType.{}".format(JEDI_clin) | translate }}
|
||||
{{ "JEDICLINType.{}".format(JEDI_clin.name) | translate }}
|
||||
</h3>
|
||||
<p class="jedi-clin-funding__subheader">Total obligated amount: {{ funds["obligated_funds"] | 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__meter-fallback' style='width:{{ (funds["expended_funds"] / funds["obligated_funds"]) * 100 }}%;'></div>
|
||||
</meter>
|
||||
<div class="jedi-clin-funding__meter-values">
|
||||
<p class="jedi-clin-funding__subheader">Total obligated amount: {{ JEDI_clin.obligated | dollars }}</p>
|
||||
<div class="jedi-clin-funding__graph">
|
||||
{% if JEDI_clin.remaining < 0 %}
|
||||
<span style="width:100%" class="jedi-clin-funding__graph-bar jedi-clin-funding__graph-bar--insufficient"></span>
|
||||
{% 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">
|
||||
<p class="jedi-clin-funding__meta-header">Funds expended:</p>
|
||||
<p class="h3 jedi-clin-funding__meta-value">{{ funds["expended_funds"] | dollars }}</p>
|
||||
<p class="jedi-clin-funding__meta-header">
|
||||
<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 class="jedi-clin-funding__meta jedi-clin-funding__meta--remaining">
|
||||
<p class="jedi-clin-funding__meta-header">Remaining funds:</p>
|
||||
<p class="h3 jedi-clin-funding__meta-value">{{ remaining_funds | dollars }}</p>
|
||||
<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--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>
|
||||
|
@ -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/icon.html" import Icon %}
|
||||
{% from "components/sticky_cta.html" import StickyCTA %}
|
||||
@ -6,96 +8,68 @@
|
||||
|
||||
{% 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="") %}
|
||||
<local-datetime timestamp="{{ dt }}" format="MMMM D, YYYY" class="{{ className }}"></local-datetime>
|
||||
{% 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 %}
|
||||
{% if task_order.has_begun %}
|
||||
Started on
|
||||
{% macro TaskOrderList(task_orders, status) %}
|
||||
<div class="accordion">
|
||||
{% 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 %}
|
||||
Starts on
|
||||
{% endif %}
|
||||
{{ 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 %}
|
||||
|
|
||||
|
||||
{% 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 class="accordion__content--empty">
|
||||
{{ "task_orders.status_empty_state" | translate({ 'status': status }) }}
|
||||
</div>
|
||||
<div class="card__header">
|
||||
<h3>Task Order #{{ task_order.number }}</h3>
|
||||
</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 %}
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% call StickyCTA(text="Funding") %}
|
||||
{% if user_can(permissions.CREATE_TASK_ORDER) %}
|
||||
<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>
|
||||
{% call StickyCTA(text="common.task_orders"|translate) %}
|
||||
{% 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">
|
||||
{{ "task_orders.add_new_button" | translate }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
@ -103,15 +77,20 @@
|
||||
|
||||
<div class="portfolio-funding">
|
||||
|
||||
{% if task_orders %}
|
||||
{{ TaskOrderList(task_orders) }}
|
||||
{% if to_count > 0 %}
|
||||
{% call AccordionList() %}
|
||||
{% for status, to_list in task_orders.items() %}
|
||||
{{ TaskOrderList(to_list, status) }}
|
||||
{% endfor %}
|
||||
{% endcall %}
|
||||
{% else %}
|
||||
{{ EmptyState(
|
||||
'This portfolio doesn’t have any active or pending task orders.',
|
||||
action_label='Add a New Task Order',
|
||||
action_href=url_for('task_orders.form_step_one_add_pdf', portfolio_id=portfolio.id),
|
||||
icon='cloud',
|
||||
add_perms=user_can(permissions.CREATE_TASK_ORDER)
|
||||
header="task_orders.empty_state.header"|translate,
|
||||
message="task_orders.empty_state.message"|translate,
|
||||
button_link=url_for('task_orders.form_step_one_add_pdf', portfolio_id=portfolio.id),
|
||||
button_text="task_orders.empty_state.button_text"|translate,
|
||||
view_only_text="task_orders.empty_state.view_only_text"|translate,
|
||||
user_can_create=user_can(permissions.CREATE_TASK_ORDER),
|
||||
) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
1
terraform/.gitignore
vendored
Normal file
1
terraform/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.terraform
|
35
terraform/modules/k8s/main.tf
Normal file
35
terraform/modules/k8s/main.tf
Normal 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
|
||||
}
|
||||
}
|
0
terraform/modules/k8s/outputs.tf
Normal file
0
terraform/modules/k8s/outputs.tf
Normal file
35
terraform/modules/k8s/variables.tf
Normal file
35
terraform/modules/k8s/variables.tf
Normal 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
|
||||
}
|
40
terraform/modules/keyvault/main.tf
Normal file
40
terraform/modules/keyvault/main.tf
Normal 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",
|
||||
]
|
||||
}
|
||||
|
24
terraform/modules/keyvault/variables.tf
Normal file
24
terraform/modules/keyvault/variables.tf
Normal 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"
|
||||
}
|
37
terraform/modules/postgres/main.tf
Normal file
37
terraform/modules/postgres/main.tf
Normal 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
|
||||
}
|
0
terraform/modules/postgres/outputs.tf
Normal file
0
terraform/modules/postgres/outputs.tf
Normal file
100
terraform/modules/postgres/variables.tf
Normal file
100
terraform/modules/postgres/variables.tf
Normal 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"
|
||||
}
|
||||
|
72
terraform/modules/vpc/main.tf
Normal file
72
terraform/modules/vpc/main.tf
Normal 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
|
||||
}
|
3
terraform/modules/vpc/outputs.tf
Normal file
3
terraform/modules/vpc/outputs.tf
Normal file
@ -0,0 +1,3 @@
|
||||
output "subnets" {
|
||||
value = azurerm_subnet.subnet["private"].id #FIXME - output should be a map
|
||||
}
|
43
terraform/modules/vpc/variables.tf
Normal file
43
terraform/modules/vpc/variables.tf
Normal 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
Loading…
x
Reference in New Issue
Block a user