Merge branch 'master' into ui/reports-spend-table

This commit is contained in:
andrewdds 2018-08-23 08:26:41 -04:00 committed by GitHub
commit 08783e60ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 1160 additions and 362 deletions

1
.gitignore vendored
View File

@ -34,3 +34,4 @@ config/dev.ini
# CRLs # CRLs
/crl /crl
/crl-tmp /crl-tmp
*.bk

View File

@ -29,6 +29,7 @@ black = "*"
pytest-watch = "*" pytest-watch = "*"
factory-boy = "*" factory-boy = "*"
pytest-flask = "*" pytest-flask = "*"
pytest-env = "*"
[requires] [requires]
python_version = "3.6" python_version = "3.6"

139
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "5fc8273838354406366b401529a6f512a73ac6a8ecea6699afa4ab7b4996bf13" "sha256": "41ad134816dae388385cfb15105e0eca436b25791ec4fbf67a2b36c4ae8056bd"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -33,10 +33,10 @@
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7", "sha256:4c1d68a1408dd090d2f3a869aa94c3947cc1d967821d1ed303208c9f41f0f2f4",
"sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0" "sha256:b6e8b28b2b7e771a41ecdd12d4d43262ecab52adebbafa42c77d6b57fb6ad3a4"
], ],
"version": "==2018.4.16" "version": "==2018.8.13"
}, },
"cffi": { "cffi": {
"hashes": [ "hashes": [
@ -91,27 +91,27 @@
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:21af753934f2f6d1a10fe8f4c0a64315af209ef6adeaee63ca349797d747d687", "sha256:02602e1672b62e803e08617ec286041cc453e8d43f093a5f4162095506bc0beb",
"sha256:27bb401a20a838d6d0ea380f08c6ead3ccd8c9d8a0232dc9adcc0e4994576a66", "sha256:10b48e848e1edb93c1d3b797c83c72b4c387ab0eb4330aaa26da8049a6cbede0",
"sha256:29720c4253263cff9aea64585adbbe85013ba647f6e98367efff9db2d7193ded", "sha256:17db09db9d7c5de130023657be42689d1a5f60502a14f6f745f6f65a6b8195c0",
"sha256:2a35b7570d8f247889784010aac8b384fd2e4a47b33e15c4a60b45a7c1944120", "sha256:227da3a896df1106b1a69b1e319dce218fa04395e8cc78be7e31ca94c21254bc",
"sha256:42c531a6a354407f42ee07fda5c2c0dc822cf6d52744949c182f2b295fbd4183", "sha256:2cbaa03ac677db6c821dac3f4cdfd1461a32d0615847eedbb0df54bb7802e1f7",
"sha256:5eb86f03f9c4f0ac2336ac5431271072ddf7ecc76b338e26366732cfac58aa19", "sha256:31db8febfc768e4b4bd826750a70c79c99ea423f4697d1dab764eb9f9f849519",
"sha256:67f7f57eae8dede577f3f7775957f5bec93edd6bdb6ce597bb5b28e1bdf3d4fb", "sha256:4a510d268e55e2e067715d728e4ca6cd26a8e9f1f3d174faf88e6f2cb6b6c395",
"sha256:6ec84edcbc966ae460560a51a90046503ff0b5b66157a9efc61515c68059f6c8", "sha256:6a88d9004310a198c474d8a822ee96a6dd6c01efe66facdf17cb692512ae5bc0",
"sha256:7ba834564daef87557e7fcd35c3c3183a4147b0b3a57314e53317360b9b201b3", "sha256:76936ec70a9b72eb8c58314c38c55a0336a2b36de0c7ee8fb874a4547cadbd39",
"sha256:7d7f084cbe1fdb82be5a0545062b59b1ad3637bc5a48612ac2eb428ff31b31ea", "sha256:7e3b4aecc4040928efa8a7cdaf074e868af32c58ffc9bb77e7bf2c1a16783286",
"sha256:82409f5150e529d699e5c33fa8fd85e965104db03bc564f5f4b6a9199e591f7c", "sha256:8168bcb08403ef144ff1fb880d416f49e2728101d02aaadfe9645883222c0aa5",
"sha256:87d092a7c2a44e5f7414ab02fb4145723ebba411425e1a99773531dd4c0e9b8d", "sha256:8229ceb79a1792823d87779959184a1bf95768e9248c93ae9f97c7a2f60376a1",
"sha256:8c56ef989342e42b9fcaba7c74b446f0cc9bed546dd00034fa7ad66fc00307ef", "sha256:8a19e9f2fe69f6a44a5c156968d9fc8df56d09798d0c6a34ccc373bb186cee86",
"sha256:9449f5d4d7c516a6118fa9210c4a00f34384cb1d2028672100ee0c6cce49d7f6", "sha256:8d10113ca826a4c29d5b85b2c4e045ffa8bad74fb525ee0eceb1d38d4c70dfd6",
"sha256:bc2301170986ad82d9349a91eb8884e0e191209c45f5541b16aa7c0cfb135978", "sha256:be495b8ec5a939a7605274b6e59fbc35e76f5ad814ae010eb679529671c9e119",
"sha256:c132bab45d4bd0fff1d3fe294d92b0a6eb8404e93337b3127bdec9f21de117e6", "sha256:dc2d3f3b1548f4d11786616cf0f4415e25b0fbecb8a1d2cd8c07568f13fdde38",
"sha256:c3d945b7b577f07a477700f618f46cbc287af3a9222cd73035c6ef527ef2c363", "sha256:e4aecdd9d5a3d06c337894c9a6e2961898d3f64fe54ca920a72234a3de0f9cb3",
"sha256:cee18beb4c807b5c0b178f4fa2fae03cef9d51821a358c6890f8b23465b7e5d2", "sha256:e79ab4485b99eacb2166f3212218dd858258f374855e1568f728462b0e6ee0d9",
"sha256:d01dfc5c2b3495184f683574e03c70022674ca9a7be88589c5aba130d835ea90" "sha256:f995d3667301e1754c57b04e0bae6f0fa9d710697a9f8d6712e8cca02550910f"
], ],
"version": "==2.3" "version": "==2.3.1"
}, },
"flask": { "flask": {
"hashes": [ "hashes": [
@ -271,7 +271,7 @@
"sha256:1d936da41ee06216d89fdc7ead1ee9a5da2811a8787515a976b646e110c3f622", "sha256:1d936da41ee06216d89fdc7ead1ee9a5da2811a8787515a976b646e110c3f622",
"sha256:e4ef42e82b0b493c5849eed98b5ab49d6767caf982127e9a33167f1153b36cc5" "sha256:e4ef42e82b0b493c5849eed98b5ab49d6767caf982127e9a33167f1153b36cc5"
], ],
"markers": "python_version != '3.0.*' and python_version != '3.1.*' and python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.3.*'", "markers": "python_version != '3.0.*' and python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.3.*'",
"version": "==2018.5" "version": "==2018.5"
}, },
"redis": { "redis": {
@ -299,10 +299,10 @@
}, },
"sqlalchemy": { "sqlalchemy": {
"hashes": [ "hashes": [
"sha256:72325e67fb85f6e9ad304c603d83626d1df684fdf0c7ab1f0352e71feeab69d8" "sha256:ef6569ad403520ee13e180e1bfd6ed71a0254192a934ec1dbd3dbf48f4aa9524"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.2.10" "version": "==1.2.11"
}, },
"unipath": { "unipath": {
"hashes": [ "hashes": [
@ -317,7 +317,7 @@
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
], ],
"markers": "python_version >= '2.6' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*' and python_version != '3.3.*' and python_version < '4'", "markers": "python_version >= '2.6' and python_version != '3.2.*' and python_version != '3.0.*' and python_version < '4' and python_version != '3.1.*' and python_version != '3.3.*'",
"version": "==1.23" "version": "==1.23"
}, },
"webassets": { "webassets": {
@ -350,14 +350,6 @@
], ],
"version": "==1.4.3" "version": "==1.4.3"
}, },
"appnope": {
"hashes": [
"sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0",
"sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71"
],
"markers": "sys_platform == 'darwin'",
"version": "==0.1.0"
},
"argh": { "argh": {
"hashes": [ "hashes": [
"sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3", "sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3",
@ -367,10 +359,10 @@
}, },
"astroid": { "astroid": {
"hashes": [ "hashes": [
"sha256:a48b57ede295c3188ef5c84273bc2a8eadc46e4cbb001eae0d49fb5d1fabbb19", "sha256:292fa429e69d60e4161e7612cb7cc8fa3609e2e309f80c224d93a76d5e7b58be",
"sha256:d066cdeec5faeb51a4be5010da612680653d844b57afd86a5c8315f2f801b4cc" "sha256:c7013d119ec95eb626f7a2011f0b63d0c9a095df9ad06d8507b37084eada1a8d"
], ],
"version": "==2.0.2" "version": "==2.0.4"
}, },
"atomicwrites": { "atomicwrites": {
"hashes": [ "hashes": [
@ -395,11 +387,11 @@
}, },
"bandit": { "bandit": {
"hashes": [ "hashes": [
"sha256:cb977045497f83ec3a02616973ab845c829cdab8144ce2e757fe031104a9abd4", "sha256:45bf1b361004e861e5b423b36ff5c700d21442753c841013c87f14a4639b1d74",
"sha256:de4cc19d6ba32d6f542c6a1ddadb4404571347d83ef1ed1e7afb7d0b38e0c25b" "sha256:a3aa04802194ec1fd290849e02b915824f9c3234623d7dcea6a33b1605ddb0ac"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.4.0" "version": "==1.5.0"
}, },
"black": { "black": {
"hashes": [ "hashes": [
@ -446,11 +438,11 @@
}, },
"faker": { "faker": {
"hashes": [ "hashes": [
"sha256:0e9a1227a3a0f3297a485715e72ee6eb77081b17b629367042b586e38c03c867", "sha256:ea7cfd3aeb1544732d08bd9cfba40c5b78e3a91e17b1a0698ab81bfc5554c628",
"sha256:b4840807a94a3bad0217d6ed3f9b65a1cc6e1db1c99e1184673056ae2c0a4c4d" "sha256:f6d67f04abfb2b4bea7afc7fa6c18cf4c523a67956e455668be9ae42bccc21ad"
], ],
"markers": "python_version != '3.1.*' and python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.0.*'", "markers": "python_version != '3.2.*' and python_version != '3.0.*' and python_version != '3.1.*' and python_version >= '2.7'",
"version": "==0.8.17" "version": "==0.9.0"
}, },
"flask": { "flask": {
"hashes": [ "hashes": [
@ -502,7 +494,7 @@
"sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8", "sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8",
"sha256:ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497" "sha256:ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497"
], ],
"markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*'", "markers": "python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.3.*'",
"version": "==4.3.4" "version": "==4.3.4"
}, },
"itsdangerous": { "itsdangerous": {
@ -620,7 +612,7 @@
"sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1", "sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1",
"sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1" "sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1"
], ],
"markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*'", "markers": "python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.3.*'",
"version": "==0.7.1" "version": "==0.7.1"
}, },
"prompt-toolkit": { "prompt-toolkit": {
@ -643,7 +635,7 @@
"sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7", "sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7",
"sha256:e31fb2767eb657cbde86c454f02e99cb846d3cd9d61b318525140214fdc0e98e" "sha256:e31fb2767eb657cbde86c454f02e99cb846d3cd9d61b318525140214fdc0e98e"
], ],
"markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*'", "markers": "python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.3.*'",
"version": "==1.5.4" "version": "==1.5.4"
}, },
"pygments": { "pygments": {
@ -663,11 +655,18 @@
}, },
"pytest": { "pytest": {
"hashes": [ "hashes": [
"sha256:86a8dbf407e437351cef4dba46736e9c5a6e3c3ac71b2e942209748e76ff2086", "sha256:3459a123ad5532852d36f6f4501dfe1acf4af1dd9541834a164666aa40395b02",
"sha256:e74466e97ac14582a8188ff4c53e6cc3810315f342f6096899332ae864c1d432" "sha256:96bfd45dbe863b447a3054145cd78a9d7f31475d2bce6111b133c0cc4f305118"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.7.1" "version": "==3.7.2"
},
"pytest-env": {
"hashes": [
"sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2"
],
"index": "pypi",
"version": "==0.6.2"
}, },
"pytest-flask": { "pytest-flask": {
"hashes": [ "hashes": [
@ -748,42 +747,6 @@
], ],
"version": "==4.3.2" "version": "==4.3.2"
}, },
"typed-ast": {
"hashes": [
"sha256:0948004fa228ae071054f5208840a1e88747a357ec1101c17217bfe99b299d58",
"sha256:10703d3cec8dcd9eef5a630a04056bbc898abc19bac5691612acba7d1325b66d",
"sha256:1f6c4bd0bdc0f14246fd41262df7dfc018d65bb05f6e16390b7ea26ca454a291",
"sha256:25d8feefe27eb0303b73545416b13d108c6067b846b543738a25ff304824ed9a",
"sha256:29464a177d56e4e055b5f7b629935af7f49c196be47528cc94e0a7bf83fbc2b9",
"sha256:2e214b72168ea0275efd6c884b114ab42e316de3ffa125b267e732ed2abda892",
"sha256:3e0d5e48e3a23e9a4d1a9f698e32a542a4a288c871d33ed8df1b092a40f3a0f9",
"sha256:519425deca5c2b2bdac49f77b2c5625781abbaf9a809d727d3a5596b30bb4ded",
"sha256:57fe287f0cdd9ceaf69e7b71a2e94a24b5d268b35df251a88fef5cc241bf73aa",
"sha256:668d0cec391d9aed1c6a388b0d5b97cd22e6073eaa5fbaa6d2946603b4871efe",
"sha256:68ba70684990f59497680ff90d18e756a47bf4863c604098f10de9716b2c0bdd",
"sha256:6de012d2b166fe7a4cdf505eee3aaa12192f7ba365beeefaca4ec10e31241a85",
"sha256:79b91ebe5a28d349b6d0d323023350133e927b4de5b651a8aa2db69c761420c6",
"sha256:8550177fa5d4c1f09b5e5f524411c44633c80ec69b24e0e98906dd761941ca46",
"sha256:898f818399cafcdb93cbbe15fc83a33d05f18e29fb498ddc09b0214cdfc7cd51",
"sha256:94b091dc0f19291adcb279a108f5d38de2430411068b219f41b343c03b28fb1f",
"sha256:a26863198902cda15ab4503991e8cf1ca874219e0118cbf07c126bce7c4db129",
"sha256:a8034021801bc0440f2e027c354b4eafd95891b573e12ff0418dec385c76785c",
"sha256:bc978ac17468fe868ee589c795d06777f75496b1ed576d308002c8a5756fb9ea",
"sha256:c05b41bc1deade9f90ddc5d988fe506208019ebba9f2578c622516fd201f5863",
"sha256:c9b060bd1e5a26ab6e8267fd46fc9e02b54eb15fffb16d112d4c7b1c12987559",
"sha256:edb04bdd45bfd76c8292c4d9654568efaedf76fe78eb246dde69bdb13b2dad87",
"sha256:f19f2a4f547505fe9072e15f6f4ae714af51b5a681a97f187971f50c283193b6"
],
"version": "==1.1.0"
},
"typing": {
"hashes": [
"sha256:3a887b021a77b292e151afb75323dea88a7bc1b3dfa92176cff8e44c8b68bddf",
"sha256:b2c689d54e1144bbcfd191b0832980a21c2dbcf7b5ff7a66248a60c90e951eb8",
"sha256:d400a9344254803a2368533e4533a4200d21eb7b6b729c173bc38201a74db3f2"
],
"version": "==3.6.4"
},
"watchdog": { "watchdog": {
"hashes": [ "hashes": [
"sha256:7e65882adb7746039b6f3876ee174952f8eaaa34491ba34333ddf1fe35de4162" "sha256:7e65882adb7746039b6f3876ee174952f8eaaa34491ba34333ddf1fe35de4162"

View File

@ -8,15 +8,36 @@ This is the user-facing web application for ATAT.
## Installation ## Installation
### Requirements ### System Requirements
See the [scriptz](https://github.com/dod-ccpo/scriptz) repository for the shared ATST uses the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
requirements and guidelines for all ATAT applications. pattern for setting up and running the project. The scripts are located in the
`scripts` directory and use script fragments in the
[scriptz](https://github.com/dod-ccpo/scriptz) repository that are shared across
ATAT repositories.
ATST requires a postgres instance (>= 9.6) for persistence. Have postgres installed Before running the setup scripts, a couple of dependencies need to be installed
and running on the default port of 5432. locally:
ATST also requires a redis instance for session management. Have redis installed and * `python` == 3.6
running on the default port of 6379. Python version 3.6 must be installed on your machine before installing `pipenv`.
You can download Python 3.6 [from python.org](https://www.python.org/downloads/)
or use your preferred system package manager.
* `pipenv`
ATST requires `pipenv` to be installed for python dependency management. `pipenv`
will create the virtual environment that the app requires. [See
`pipenv`'s documentation for instructions on installing `pipenv](
https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv).
* `postgres` >= 9.6
ATST requires a PostgreSQL instance (>= 9.6) for persistence. Have PostgresSQL installed
and running on the default port of 5432. You can verify that PostgresSQL is running
by executing `psql` and ensuring that a connection is successfully made.
* `redis`
ATST also requires a Redis instance for session management. Have Redis installed and
running on the default port of 6379. You can ensure that Redis is running by
executing `redis-cli` with no options and ensuring a connection is succesfully made.
### Cloning ### Cloning
This project contains git submodules. Here is an example clone command that will This project contains git submodules. Here is an example clone command that will
@ -62,6 +83,10 @@ To watch for changes to any js/css assets:
yarn watch yarn watch
After running `script/dev_server`, the application is available at
[`http://localhost:8000`](http://localhost:8000).
### Users ### Users
There are currently six mock users for development: There are currently six mock users for development:
@ -73,7 +98,11 @@ There are currently six mock users for development:
- Dominick - Dominick
- Erica - Erica
To log in as one of them, navigate to `/login-dev?username=<lowercase name>`. For example `/login-dev?username=amanda`. To log in as one of them, navigate to `/login-dev?username=<lowercase name>`.
For example `/login-dev?username=amanda`.
In development mode, there is a `DEV Login` button available on the home page
that will automatically log you in as Amanda.
## Testing ## Testing

View File

@ -0,0 +1,37 @@
"""add workspaces table
Revision ID: 4be312655ceb
Revises: 05d6272bdb43
Create Date: 2018-08-16 09:25:19.888549
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '4be312655ceb'
down_revision = '05d6272bdb43'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('workspaces',
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('request_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('task_order_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['request_id'], ['requests.id'], ),
sa.ForeignKeyConstraint(['task_order_id'], ['task_order.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('workspaces')
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""workspace timestamps
Revision ID: a2b499a1dd62
Revises: f549c7cee17c
Create Date: 2018-08-17 10:43:13.165829
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a2b499a1dd62'
down_revision = 'f549c7cee17c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('workspaces', sa.Column('time_created', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False))
op.add_column('workspaces', sa.Column('time_updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('workspaces', 'time_updated')
op.drop_column('workspaces', 'time_created')
# ### end Alembic commands ###

View File

@ -0,0 +1,47 @@
"""projects and environments
Revision ID: f064247f2988
Revises: a2b499a1dd62
Create Date: 2018-08-17 11:30:53.684954
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'f064247f2988'
down_revision = 'a2b499a1dd62'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('projects',
sa.Column('time_created', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('time_updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=False),
sa.Column('workspace_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('environments',
sa.Column('time_created', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('time_updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('project_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('environments')
op.drop_table('projects')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""add workspace_role workspace_id fk
Revision ID: f36f130622b9
Revises: f064247f2988
Create Date: 2018-08-20 10:36:23.920881
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f36f130622b9'
down_revision = 'f064247f2988'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_foreign_key('workspace_role_workspace_id_fk', 'workspace_role', 'workspaces', ['workspace_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('workspace_role_workspace_id_fk', 'workspace_role', type_='foreignkey')
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""remove workspaces task order association
Revision ID: f549c7cee17c
Revises: 4be312655ceb
Create Date: 2018-08-16 16:42:48.581510
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f549c7cee17c'
down_revision = '4be312655ceb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('workspaces_task_order_id_fkey', 'workspaces', type_='foreignkey')
op.drop_column('workspaces', 'task_order_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('workspaces', sa.Column('task_order_id', sa.INTEGER(), autoincrement=False))
op.create_foreign_key('workspaces_task_order_id_fkey', 'workspaces', 'task_order', ['task_order_id'], ['id'])
# ### end Alembic commands ###

View File

@ -18,6 +18,7 @@ from atst.routes.dev import bp as dev_routes
from atst.routes.errors import make_error_pages from atst.routes.errors import make_error_pages
from atst.domain.authnid.crl import CRLCache from atst.domain.authnid.crl import CRLCache
from atst.domain.auth import apply_authentication from atst.domain.auth import apply_authentication
from atst.eda_client import MockEDAClient
ENV = os.getenv("FLASK_ENV", "dev") ENV = os.getenv("FLASK_ENV", "dev")
@ -41,6 +42,7 @@ def make_app(config):
make_flask_callbacks(app) make_flask_callbacks(app)
make_crl_validator(app) make_crl_validator(app)
register_filters(app) register_filters(app)
make_eda_client(app)
db.init_app(app) db.init_app(app)
csrf.init_app(app) csrf.init_app(app)
@ -62,11 +64,6 @@ def make_app(config):
def make_flask_callbacks(app): def make_flask_callbacks(app):
@app.before_request @app.before_request
def _set_globals(): def _set_globals():
g.navigationContext = (
"workspace"
if re.match("\/workspaces\/[A-Za-z0-9]*", request.path)
else "global"
)
g.dev = os.getenv("FLASK_ENV", "dev") == "dev" g.dev = os.getenv("FLASK_ENV", "dev") == "dev"
g.matchesPath = lambda href: re.match("^" + href, request.path) g.matchesPath = lambda href: re.match("^" + href, request.path)
g.modal = request.args.get("modal", None) g.modal = request.args.get("modal", None)
@ -139,3 +136,5 @@ def make_crl_validator(app):
crl_locations.append(filename.absolute()) crl_locations.append(filename.absolute())
app.crl_cache = CRLCache(app.config["CA_CHAIN"], crl_locations, logger=app.logger) app.crl_cache = CRLCache(app.config["CA_CHAIN"], crl_locations, logger=app.logger)
def make_eda_client(app):
app.eda_client = MockEDAClient()

12
atst/domain/authz.py Normal file
View File

@ -0,0 +1,12 @@
from atst.domain.workspace_users import WorkspaceUsers
class Authorization(object):
@classmethod
def has_workspace_permission(cls, user, workspace, permission):
workspace_user = WorkspaceUsers.get(workspace.id, user.id)
return permission in workspace_user.permissions()
@classmethod
def is_in_workspace(cls, user, workspace):
return user in workspace.users

View File

@ -0,0 +1,12 @@
from atst.database import db
from atst.models.environment import Environment
class Environments(object):
@classmethod
def create(cls, project, name):
environment = Environment(project=project, name=name)
db.session.add(environment)
db.session.commit()
return environment

13
atst/domain/projects.py Normal file
View File

@ -0,0 +1,13 @@
from atst.database import db
from atst.models.project import Project
class Projects(object):
@classmethod
def create(cls, workspace, name, description):
project = Project(workspace=workspace, name=name, description=description)
db.session.add(project)
db.session.commit()
return project

View File

@ -6,6 +6,7 @@ from sqlalchemy.orm.attributes import flag_modified
from atst.models.request import Request from atst.models.request import Request
from atst.models.request_status_event import RequestStatusEvent, RequestStatus from atst.models.request_status_event import RequestStatusEvent, RequestStatus
from atst.domain.workspaces import Workspaces
from atst.database import db from atst.database import db
from .exceptions import NotFoundError from .exceptions import NotFoundError
@ -31,6 +32,7 @@ def deep_merge(source, destination: dict):
class Requests(object): class Requests(object):
AUTO_APPROVE_THRESHOLD = 1000000 AUTO_APPROVE_THRESHOLD = 1000000
ANNUAL_SPEND_THRESHOLD = 1000000
@classmethod @classmethod
def create(cls, creator, body): def create(cls, creator, body):
@ -114,6 +116,18 @@ class Requests(object):
db.session.add(request) db.session.add(request)
db.session.commit() db.session.commit()
return request
@classmethod
def approve_and_create_workspace(cls, request):
approved_request = Requests.set_status(request, RequestStatus.APPROVED)
workspace = Workspaces.create(approved_request)
db.session.add(approved_request)
db.session.commit()
return workspace
@classmethod @classmethod
def set_status(cls, request: Request, status: RequestStatus): def set_status(cls, request: Request, status: RequestStatus):
status_event = RequestStatusEvent(new_status=status) status_event = RequestStatusEvent(new_status=status)

View File

@ -1,4 +1,5 @@
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from flask import current_app as app
from atst.database import db from atst.database import db
from atst.models.task_order import TaskOrder from atst.models.task_order import TaskOrder
@ -8,12 +9,36 @@ from .exceptions import NotFoundError
class TaskOrders(object): class TaskOrders(object):
@classmethod @classmethod
def get(self, order_number): def get(cls, order_number):
try: try:
task_order = ( task_order = (
db.session.query(TaskOrder).filter_by(number=order_number).one() db.session.query(TaskOrder).filter_by(number=order_number).one()
) )
except NoResultFound: except NoResultFound:
raise NotFoundError("task_order") if TaskOrders._client():
task_order = TaskOrders._get_from_eda(order_number)
else:
raise NotFoundError("task_order")
return task_order return task_order
@classmethod
def _get_from_eda(cls, order_number):
to_data = TaskOrders._client().get_contract(order_number, status="y")
if to_data:
return TaskOrders.create(to_data["contract_no"])
else:
raise NotFoundError("task_order")
@classmethod
def create(cls, order_number):
task_order = TaskOrder(number=order_number)
db.session.add(task_order)
db.session.commit()
return task_order
@classmethod
def _client(cls):
return app.eda_client

View File

@ -21,7 +21,8 @@ class WorkspaceUsers(object):
try: try:
workspace_role = ( workspace_role = (
WorkspaceRole.query.join(User) db.session.query(WorkspaceRole)
.join(User)
.filter(User.id == user_id, WorkspaceRole.workspace_id == workspace_id) .filter(User.id == user_id, WorkspaceRole.workspace_id == workspace_id)
.one() .one()
) )
@ -30,6 +31,29 @@ class WorkspaceUsers(object):
return WorkspaceUser(user, workspace_role) return WorkspaceUser(user, workspace_role)
@classmethod
def add(cls, user, workspace_id, role_name):
role = Roles.get(role_name)
try:
existing_workspace_role = (
db.session.query(WorkspaceRole)
.filter(
WorkspaceRole.user == user,
WorkspaceRole.workspace_id == workspace_id,
)
.one()
)
new_workspace_role = existing_workspace_role
new_workspace_role.role = role
except NoResultFound:
new_workspace_role = WorkspaceRole(
user=user, role_id=role.id, workspace_id=workspace_id
)
user.workspace_roles.append(new_workspace_role)
db.session.add(user)
db.session.commit()
@classmethod @classmethod
def add_many(cls, workspace_id, workspace_user_dicts): def add_many(cls, workspace_id, workspace_user_dicts):
workspace_users = [] workspace_users = []

View File

@ -1,90 +1,71 @@
class Projects(object): from sqlalchemy.orm.exc import NoResultFound
def __init__(self):
pass
def create(self, creator_id, body): from atst.database import db
pass from atst.models.workspace import Workspace
from atst.models.workspace_role import WorkspaceRole
def get(self, project_id): from atst.domain.exceptions import NotFoundError, UnauthorizedError
pass from atst.domain.roles import Roles
from atst.domain.authz import Authorization
def get_many(self, workspace_id): from atst.models.permissions import Permissions
return [
{
"id": "187c9bea-9541-45d7-801f-cf8e7a642e93",
"name": "Code.mil",
"environments": [
{
"id": "b1154fdd-31c9-437f-b580-2e4d757de5cb",
"name": "Development",
},
{"id": "b1e2077a-6a3d-4e7f-a80c-bf1143433adf", "name": "Sandbox"},
{
"id": "8ea95eea-7cc0-4500-adf7-8a13eaa6b752",
"name": "production",
},
],
},
{
"id": "ececfd73-b19d-45aa-9199-a950ba2c7269",
"name": "Digital Dojo",
"environments": [
{
"id": "f56167cb-ca3d-4e29-8b60-91052957a118",
"name": "Development",
},
{
"id": "7c18689c-5b77-4b68-8d64-d4d8a830bf47",
"name": "production",
},
],
},
]
def update(self, request_id, request_delta):
pass
class Members(object): class Workspaces(object):
def __init__(self): @classmethod
pass def create(cls, request, name=None):
name = name or request.id
workspace = Workspace(request=request, name=name)
Workspaces._create_workspace_role(request.creator, workspace, "owner")
def create(self, creator_id, body): db.session.add(workspace)
pass db.session.commit()
def get(self, request_id): return workspace
pass
def get_many(self, workspace_id): @classmethod
return [ def get(cls, user, workspace_id):
{ try:
"first_name": "Danny", workspace = db.session.query(Workspace).filter_by(id=workspace_id).one()
"last_name": "Knight", except NoResultFound:
"email": "dknight@thenavy.mil", raise NotFoundError("workspace")
"dod_id": "1257892124",
"workspace_role": "Developer",
"status": "Pending",
"num_projects": "4",
},
{
"first_name": "Mario",
"last_name": "Hudson",
"email": "mhudson@thearmy.mil",
"dod_id": "4357892125",
"workspace_role": "CCPO",
"status": "Active",
"num_projects": "0",
},
{
"first_name": "Louise",
"last_name": "Greer",
"email": "lgreer@theairforce.mil",
"dod_id": "7257892125",
"workspace_role": "Admin",
"status": "Pending",
"num_projects": "43",
},
]
def update(self, request_id, request_delta): if not Authorization.is_in_workspace(user, workspace):
pass raise UnauthorizedError(user, "get workspace")
return workspace
@classmethod
def get_for_update(cls, user, workspace_id):
workspace = Workspaces.get(user, workspace_id)
if not Authorization.has_workspace_permission(
user, workspace, Permissions.ADD_APPLICATION_IN_WORKSPACE
):
raise UnauthorizedError(user, "add project")
return workspace
@classmethod
def get_by_request(cls, request):
try:
workspace = db.session.query(Workspace).filter_by(request=request).one()
except NoResultFound:
raise NotFoundError("workspace")
return workspace
@classmethod
def get_many(cls, user):
workspaces = (
db.session.query(Workspace)
.join(WorkspaceRole)
.filter(WorkspaceRole.user == user)
.all()
)
return workspaces
@classmethod
def _create_workspace_role(cls, user, workspace, role_name):
role = Roles.get(role_name)
workspace_role = WorkspaceRole(
user=user, role=role, workspace=workspace
)
db.session.add(workspace_role)
return workspace_role

View File

@ -71,8 +71,10 @@ class MockEDAClient(EDAClientBase):
}, },
] ]
MOCK_CONTRACT_NUMBER = "DCA10096D0052"
def get_contract(self, contract_number, status): def get_contract(self, contract_number, status):
if contract_number == "DCA10096D0052" and status == "y": if contract_number == self.MOCK_CONTRACT_NUMBER and status == "y":
return { return {
"aco_mod": "01", "aco_mod": "01",
"admin_dodaac": None, "admin_dodaac": None,

View File

@ -1,10 +1,11 @@
import re import re
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
from wtforms.fields import StringField from wtforms.fields import StringField
from wtforms.validators import Required, Email, Regexp from wtforms.validators import Required, Email, Regexp, ValidationError
from atst.domain.exceptions import NotFoundError from atst.domain.exceptions import NotFoundError
from atst.domain.pe_numbers import PENumbers from atst.domain.pe_numbers import PENumbers
from atst.domain.task_orders import TaskOrders
from .fields import NewlineListField, SelectField from .fields import NewlineListField, SelectField
from .forms import ValidatedForm from .forms import ValidatedForm
@ -57,12 +58,7 @@ def validate_pe_id(field, existing_request):
return True return True
class FinancialForm(ValidatedForm): class BaseFinancialForm(ValidatedForm):
def validate(self, *args, **kwargs):
if self.funding_type.data == "OTHER":
self.funding_type_other.validators.append(Required())
return super().validate(*args, **kwargs)
def reset(self): def reset(self):
""" """
Reset UII info so that it can be de-parsed rendered properly. Reset UII info so that it can be de-parsed rendered properly.
@ -76,7 +72,11 @@ class FinancialForm(ValidatedForm):
valid = validate_pe_id(self.pe_id, existing_request) valid = validate_pe_id(self.pe_id, existing_request)
return valid return valid
task_order_id = StringField( @property
def is_missing_task_order_number(self):
return False
task_order_number = StringField(
"Task Order Number associated with this request", "Task Order Number associated with this request",
description="Include the original Task Order number (including the 000X at the end). Do not include any modification numbers. Note that there may be a lag between approving a task order and when it becomes available in our system.", description="Include the original Task Order number (including the 000X at the end). Do not include any modification numbers. Note that there may be a lag between approving a task order and when it becomes available in our system.",
validators=[Required()] validators=[Required()]
@ -117,6 +117,25 @@ class FinancialForm(ValidatedForm):
"Contracting Officer Representative (COR) Office", validators=[Required()] "Contracting Officer Representative (COR) Office", validators=[Required()]
) )
class FinancialForm(BaseFinancialForm):
def validate_task_order_number(form, field):
try:
TaskOrders.get(field.data)
except NotFoundError:
raise ValidationError("Task Order number not found")
@property
def is_missing_task_order_number(self):
return "task_order_number" in self.errors
class ExtendedFinancialForm(BaseFinancialForm):
def validate(self, *args, **kwargs):
if self.funding_type.data == "OTHER":
self.funding_type_other.validators.append(Required())
return super().validate(*args, **kwargs)
funding_type = SelectField( funding_type = SelectField(
description="What is the source of funding?", description="What is the source of funding?",
choices=[ choices=[

View File

@ -0,0 +1,9 @@
from flask_wtf import Form
from wtforms.fields import StringField, TextAreaField
class NewProjectForm(Form):
name = StringField(label="Project Name")
description = TextAreaField(label="Description")
environment_name = StringField(label="Environment Name")

View File

@ -10,3 +10,6 @@ from .user import User
from .workspace_role import WorkspaceRole from .workspace_role import WorkspaceRole
from .pe_number import PENumber from .pe_number import PENumber
from .task_order import TaskOrder from .task_order import TaskOrder
from .workspace import Workspace
from .project import Project
from .environment import Environment

View File

@ -0,0 +1,16 @@
from sqlalchemy import Column, ForeignKey, String
from sqlalchemy.orm import relationship
from atst.models import Base
from atst.models.types import Id
from atst.models.mixins import TimestampsMixin
class Environment(Base, TimestampsMixin):
__tablename__ = "environments"
id = Id()
name = Column(String, nullable=False)
project_id = Column(ForeignKey("projects.id"))
project = relationship("Project")

7
atst/models/mixins.py Normal file
View File

@ -0,0 +1,7 @@
from sqlalchemy import Column, func, TIMESTAMP
class TimestampsMixin(object):
time_created = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now())
time_updated = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now(), onupdate=func.current_timestamp())

18
atst/models/project.py Normal file
View File

@ -0,0 +1,18 @@
from sqlalchemy import Column, ForeignKey, String
from sqlalchemy.orm import relationship
from atst.models import Base
from atst.models.types import Id
from atst.models.mixins import TimestampsMixin
class Project(Base, TimestampsMixin):
__tablename__ = "projects"
id = Id()
name = Column(String, nullable=False)
description = Column(String, nullable=False)
workspace_id = Column(ForeignKey("workspaces.id"), nullable=False)
workspace = relationship("Workspace")
environments = relationship("Environment", back_populates="project")

75
atst/models/workspace.py Normal file
View File

@ -0,0 +1,75 @@
from sqlalchemy import Column, ForeignKey, String
from sqlalchemy.orm import relationship
from atst.models import Base
from atst.models.types import Id
from atst.models.mixins import TimestampsMixin
MOCK_MEMBERS = [
{
"first_name": "Danny",
"last_name": "Knight",
"email": "dknight@thenavy.mil",
"dod_id": "1257892124",
"workspace_role": "Developer",
"status": "Pending",
"num_projects": "4",
},
{
"first_name": "Mario",
"last_name": "Hudson",
"email": "mhudson@thearmy.mil",
"dod_id": "4357892125",
"workspace_role": "CCPO",
"status": "Active",
"num_projects": "0",
},
{
"first_name": "Louise",
"last_name": "Greer",
"email": "lgreer@theairforce.mil",
"dod_id": "7257892125",
"workspace_role": "Admin",
"status": "Pending",
"num_projects": "43",
},
]
class Workspace(Base, TimestampsMixin):
__tablename__ = "workspaces"
id = Id()
name = Column(String, unique=True)
request_id = Column(ForeignKey("requests.id"), nullable=False)
request = relationship("Request")
projects = relationship("Project", back_populates="workspace")
roles = relationship("WorkspaceRole")
@property
def owner(self):
return next(
(
workspace_role.user
for workspace_role in self.roles
if workspace_role.role.name == "owner"
),
None,
)
@property
def users(self):
return set(role.user for role in self.roles)
@property
def user_count(self):
return len(self.users)
@property
def task_order(self):
return {"number": "task-order-number"}
@property
def members(self):
return MOCK_MEMBERS

View File

@ -10,11 +10,14 @@ class WorkspaceRole(Base):
__tablename__ = "workspace_role" __tablename__ = "workspace_role"
id = Id() id = Id()
workspace_id = Column(UUID(as_uuid=True), index=True) workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), index=True)
workspace = relationship("Workspace", back_populates="roles")
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id")) role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"))
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
role = relationship("Role") role = relationship("Role")
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
Index( Index(
"workspace_role_user_workspace", "workspace_role_user_workspace",

View File

@ -46,7 +46,10 @@ def login_redirect():
user = auth_context.get_user() user = auth_context.get_user()
session["user_id"] = user.id session["user_id"] = user.id
return redirect(url_for("atst.home")) if user.atat_role.name == "ccpo":
return redirect(url_for("atst.home"))
else:
return redirect(url_for("requests.requests_index"))
def _is_valid_certificate(request): def _is_valid_certificate(request):

View File

@ -61,4 +61,8 @@ def login_dev():
email=user_data["email"] email=user_data["email"]
) )
session["user_id"] = user.id session["user_id"] = user.id
return redirect(url_for("atst.home"))
if user.atat_role.name == "ccpo":
return redirect(url_for("atst.home"))
else:
return redirect(url_for("requests.requests_index"))

View File

@ -1,7 +1,13 @@
from flask import Blueprint from flask import Blueprint
from atst.domain.requests import Requests
requests_bp = Blueprint("requests", __name__) requests_bp = Blueprint("requests", __name__)
from . import index from . import index
from . import requests_form from . import requests_form
from . import financial_verification from . import financial_verification
@requests_bp.context_processor
def annual_spend_threshold():
return { "annual_spend_threshold": Requests.ANNUAL_SPEND_THRESHOLD }

View File

@ -3,15 +3,25 @@ from flask import request as http_request
from . import requests_bp from . import requests_bp
from atst.domain.requests import Requests from atst.domain.requests import Requests
from atst.forms.financial import FinancialForm from atst.forms.financial import FinancialForm, ExtendedFinancialForm
def financial_form(data):
if http_request.args.get("extended"):
return ExtendedFinancialForm(data=data)
else:
return FinancialForm(data=data)
@requests_bp.route("/requests/verify/<string:request_id>", methods=["GET"]) @requests_bp.route("/requests/verify/<string:request_id>", methods=["GET"])
def financial_verification(request_id=None): def financial_verification(request_id=None):
request = Requests.get(request_id) request = Requests.get(request_id)
form = FinancialForm(data=request.body.get("financial_verification")) form = financial_form(request.body.get("financial_verification"))
return render_template( return render_template(
"requests/financial_verification.html", f=form, request_id=request_id "requests/financial_verification.html",
f=form,
request_id=request_id,
extended=http_request.args.get("extended"),
) )
@ -19,23 +29,27 @@ def financial_verification(request_id=None):
def update_financial_verification(request_id): def update_financial_verification(request_id):
post_data = http_request.form post_data = http_request.form
existing_request = Requests.get(request_id) existing_request = Requests.get(request_id)
form = FinancialForm(post_data) form = financial_form(post_data)
rerender_args = dict(request_id=request_id, f=form) rerender_args = dict(
request_id=request_id, f=form, extended=http_request.args.get("extended")
)
if form.validate(): if form.validate():
request_data = {"financial_verification": form.data} request_data = {"financial_verification": form.data}
valid = form.perform_extra_validation( valid = form.perform_extra_validation(
existing_request.body.get("financial_verification") existing_request.body.get("financial_verification")
) )
Requests.update(request_id, request_data) updated_request = Requests.update(request_id, request_data)
if valid: if valid:
return redirect(url_for("requests.financial_verification_submitted")) new_workspace = Requests.approve_and_create_workspace(updated_request)
return redirect(url_for("workspaces.workspace_projects", workspace_id=new_workspace.id, newWorkspace=True))
else: else:
form.reset() form.reset()
return render_template( return render_template(
"requests/financial_verification.html", **rerender_args "requests/financial_verification.html", **rerender_args
) )
else: else:
form.reset() form.reset()
return render_template("requests/financial_verification.html", **rerender_args) return render_template("requests/financial_verification.html", **rerender_args)

View File

@ -1,45 +1,76 @@
from flask import Blueprint, render_template from flask import (
Blueprint,
from atst.domain.workspaces import Projects, Members render_template,
request as http_request,
g,
redirect,
url_for,
)
from atst.domain.workspaces import Workspaces
from atst.domain.projects import Projects
from atst.domain.environments import Environments
from atst.forms.new_project import NewProjectForm
bp = Blueprint("workspaces", __name__) bp = Blueprint("workspaces", __name__)
mock_workspaces = [
{ @bp.context_processor
"name": "Unclassified IaaS and PaaS for Defense Digital Service (DDS)", def workspace():
"id": "5966187a-eff9-44c3-aa15-4de7a65ac7ff", workspace = None
"task_order": {"number": 123456}, if "workspace_id" in http_request.view_args:
"user_count": 23, workspace = Workspaces.get(
} g.current_user, http_request.view_args["workspace_id"]
] )
return {"workspace": workspace}
@bp.route("/workspaces") @bp.route("/workspaces")
def workspaces(): def workspaces():
return render_template("workspaces.html", page=5, workspaces=mock_workspaces) workspaces = Workspaces.get_many(g.current_user)
return render_template("workspaces.html", page=5, workspaces=workspaces)
@bp.route("/workspaces/<workspace_id>/projects") @bp.route("/workspaces/<workspace_id>/projects")
def workspace_projects(workspace_id): def workspace_projects(workspace_id):
projects_repo = Projects() workspace = Workspaces.get(g.current_user, workspace_id)
projects = projects_repo.get_many(workspace_id) return render_template("workspace_projects.html", workspace=workspace)
return render_template(
"workspace_projects.html", workspace_id=workspace_id, projects=projects
) @bp.route("/workspaces/<workspace_id>")
def show_workspace(workspace_id):
return redirect(url_for("workspaces.workspace_projects", workspace_id=workspace_id))
@bp.route("/workspaces/<workspace_id>/members") @bp.route("/workspaces/<workspace_id>/members")
def workspace_members(workspace_id): def workspace_members(workspace_id):
members_repo = Members() workspace = Workspaces.get(g.current_user, workspace_id)
members = members_repo.get_many(workspace_id) return render_template("workspace_members.html", workspace=workspace)
return render_template(
"workspace_members.html", workspace_id=workspace_id, members=members
)
@bp.route("/workspaces/<workspace_id>/reports") @bp.route("/workspaces/<workspace_id>/reports")
def workspace_reports(workspace_id): def workspace_reports(workspace_id):
return render_template( return render_template("workspace_reports.html", workspace_id=workspace_id)
"workspace_reports.html", workspace_id=workspace_id
)
@bp.route("/workspaces/<workspace_id>/projects/new")
def new_project(workspace_id):
workspace = Workspaces.get_for_update(g.current_user, workspace_id)
form = NewProjectForm()
return render_template("workspace_project_new.html", workspace=workspace, form=form)
@bp.route("/workspaces/<workspace_id>/projects", methods=["POST"])
def update_project(workspace_id):
workspace = Workspaces.get_for_update(g.current_user, workspace_id)
form = NewProjectForm(http_request.form)
if form.validate():
project_data = form.data
project = Projects.create(
workspace, project_data["name"], project_data["description"]
)
Environments.create(project, project_data["environment_name"])
return redirect(
url_for("workspaces.workspace_projects", workspace_id=workspace.id)
)

View File

@ -12,7 +12,7 @@ metadata:
name: atst name: atst
namespace: atat namespace: atat
spec: spec:
replicas: 1 replicas: 2
strategy: strategy:
type: RollingUpdate type: RollingUpdate
template: template:
@ -24,10 +24,10 @@ spec:
fsGroup: 101 fsGroup: 101
containers: containers:
- name: atst - name: atst
image: registry.atat.codes:443/atst-prod:23e5c04 image: registry.atat.codes:443/atst-prod:e38bc2f
resources: resources:
requests: requests:
memory: "6000Mi" memory: "2500Mi"
envFrom: envFrom:
- configMapRef: - configMapRef:
name: atst-envvars name: atst-envvars

View File

@ -0,0 +1,46 @@
import textinput from '../text_input'
export default {
name: 'new-project',
components: {
textinput
},
props: {
initialData: {
type: Object,
default: () => ({})
}
},
data: function () {
const {
name,
description,
environments = ['']
} = this.initialData
return {
name,
description,
environments,
}
},
mounted: function () {
this.$root.$on('onEnvironmentAdded', this.addEnvironment)
},
methods: {
addEnvironment: function (event) {
this.environments.push('')
},
removeEnvironment: function (index) {
if (this.environments.length > 1) {
this.environments.splice(index, 1)
}
}
}
}

View File

@ -9,6 +9,7 @@ import DetailsOfUse from './components/forms/details_of_use'
import poc from './components/forms/poc' import poc from './components/forms/poc'
import financial from './components/forms/financial' import financial from './components/forms/financial'
import toggler from './components/toggler' import toggler from './components/toggler'
import NewProject from './components/forms/new_project'
Vue.use(VTooltip) Vue.use(VTooltip)
@ -22,6 +23,7 @@ const app = new Vue({
DetailsOfUse, DetailsOfUse,
poc, poc,
financial, financial,
NewProject
}, },
methods: { methods: {
closeModal: function(name) { closeModal: function(name) {

View File

@ -1,2 +1,4 @@
[pytest] [pytest]
norecursedirs = .venv .git node_modules norecursedirs = .venv .git node_modules
env =
D:FLASK_ENV=test

View File

@ -8,6 +8,8 @@ sys.path.append(parent_dir)
from atst.app import make_config, make_app from atst.app import make_config, make_app
from atst.domain.users import Users from atst.domain.users import Users
from atst.domain.requests import Requests from atst.domain.requests import Requests
from atst.domain.workspaces import Workspaces
from atst.domain.projects import Projects
from atst.domain.exceptions import AlreadyExistsError from atst.domain.exceptions import AlreadyExistsError
from tests.factories import RequestFactory from tests.factories import RequestFactory
from atst.routes.dev import _DEV_USERS as DEV_USERS from atst.routes.dev import _DEV_USERS as DEV_USERS
@ -23,11 +25,20 @@ def seed_db():
pass pass
for user in users: for user in users:
requests = []
for dollar_value in [1, 200, 3000, 40000, 500000, 1000000]: for dollar_value in [1, 200, 3000, 40000, 500000, 1000000]:
request = Requests.create( request = Requests.create(
user, RequestFactory.build_request_body(user, dollar_value) user, RequestFactory.build_request_body(user, dollar_value)
) )
Requests.submit(request) Requests.submit(request)
requests.append(request)
workspace = Workspaces.create(requests[0], name="{}'s workspace".format(user.first_name))
Projects.create(
workspace=workspace,
name="First Project",
description="This is our first project."
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -15,11 +15,16 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
.icon-tooltip {
padding: 0.25rem 0.5rem;
}
} }
@mixin block-list__title { @mixin block-list__title {
@include h4; @include h4;
margin: 0; margin: 0;
line-height: 3rem;
} }
@mixin block-list__footer { @mixin block-list__footer {

View File

@ -22,6 +22,7 @@
background: none; background: none;
transition: background-color $hover-transition-time; transition: background-color $hover-transition-time;
border-radius: $gap / 2; border-radius: $gap / 2;
cursor: pointer;
.icon { .icon {
@include icon-color($color-primary); @include icon-color($color-primary);

View File

@ -49,4 +49,8 @@
&.icon--large { &.icon--large {
@include icon-size(24); @include icon-size(24);
} }
&.icon--remove {
@include icon-color($color-red);
}
} }

View File

@ -58,7 +58,7 @@
} }
.usa-input { .usa-input {
margin: ($gap * 4) ($gap * 2) ($gap * 4) 0; margin: ($gap * 4) ($gap * 2) ($gap * 4) 0;
@include media($medium-screen) { @include media($medium-screen) {
margin: ($gap * 4) 0; margin: ($gap * 4) 0;

View File

@ -63,13 +63,23 @@
.panel__heading { .panel__heading {
margin: $gap * 2; margin: $gap * 2;
@include media($medium-screen) { @include media($medium-screen) {
margin: $gap * 4; margin: $gap * 4;
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
margin: 0; margin: 0;
display: inline-block;
}
.icon-tooltip {
margin-left: $gap*2;
}
&--grow {
display: flex;
flex-direction: row;
justify-content: space-between;
} }
} }
} }

View File

@ -4,6 +4,10 @@
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
.usa-input {
margin: 0;
}
.project-list-item__environment__link { .project-list-item__environment__link {
@include icon-link; @include icon-link;
@include icon-link-large; @include icon-link-large;

View File

@ -1,7 +1,3 @@
{# TODO: set this context elsewhere #}
{# set context='workspace' #}
{% set context=g.navigationContext %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>

View File

@ -2,8 +2,8 @@
{% macro Tooltip(message,title='Help') -%} {% macro Tooltip(message,title='Help') -%}
<span class="icon-tooltip" v-tooltip.top="{content: '{{message}}'}"> <button type="button" tabindex="0" class="icon-tooltip" v-tooltip.top="{content: '{{message}}', container: false}">
{{ Icon('help') }}<span>{{ title }}</span> {{ Icon('help') }}<span>{{ title }}</span>
</span> </button>
{%- endmacro %} {%- endmacro %}

View File

@ -1,7 +1,3 @@
{# TODO: set this context elsewhere #}
{# set context='workspace' #}
{% set context=g.navigationContext %}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>

View File

@ -1,6 +1,6 @@
{% from "components/sidenav_item.html" import SidenavItem %} {% from "components/sidenav_item.html" import SidenavItem %}
<div class="global-navigation sidenav global-navigation__context--{{context}}"> <div class="global-navigation sidenav">
<ul> <ul>
{% if g.dev %} {% if g.dev %}
{{ SidenavItem("Styleguide", {{ SidenavItem("Styleguide",

View File

@ -6,9 +6,9 @@
{{ Icon('shield', classes='topbar__link-icon') }} {{ Icon('shield', classes='topbar__link-icon') }}
</a> </a>
<div class="topbar__context topbar__context--{{context}}"> <div class="topbar__context {% if workspace %}topbar__context--workspace{% endif %}">
<a href="/" class="topbar__link"> <a href="/" class="topbar__link">
<span class="topbar__link-label">{{ "Workspace 123456" if context == 'workspace' else "JEDI" }}</span> <span class="topbar__link-label">{{ ("Workspace " + workspace.name) if workspace else "JEDI" }}</span>
{{ Icon('caret_down', classes='topbar__link-icon icon--tiny') }} {{ Icon('caret_down', classes='topbar__link-icon icon--tiny') }}
</a> </a>

View File

@ -4,13 +4,13 @@
<ul> <ul>
{{ SidenavItem( {{ SidenavItem(
"Projects", "Projects",
href=url_for("workspaces.workspace_projects", workspace_id=123456), href=url_for("workspaces.workspace_projects", workspace_id=workspace.id),
active=g.matchesPath('\/workspaces\/[A-Za-z0-9]*\/projects'), active=request.url_rule.rule.startswith('/workspaces/<workspace_id>/projects'),
subnav=[ subnav=[
{ {
"label": "Add New Project", "label": "Add New Project",
"href":"/", "href": url_for('workspaces.new_project', workspace_id=workspace.id),
"active": g.matchesPath('workspaces/projects/new'), "active": g.matchesPath('\/workspaces\/[A-Za-z0-9-]*\/projects'),
"icon": "plus" "icon": "plus"
} }
] ]
@ -18,8 +18,8 @@
{{ SidenavItem( {{ SidenavItem(
"Members", "Members",
href=url_for("workspaces.workspace_members", workspace_id=123456), href=url_for("workspaces.workspace_members", workspace_id=workspace.id),
active=g.matchesPath('\/workspaces\/[A-Za-z0-9]*\/members'), active=request.url_rule.rule.startswith('/workspaces/<workspace_id>/members'),
subnav=[ subnav=[
{ {
"label": "Add New Member", "label": "Add New Member",
@ -32,8 +32,8 @@
{{ SidenavItem( {{ SidenavItem(
"Funding & Reports", "Funding & Reports",
href='/workspaces/123456/reports', href=url_for("workspaces.workspace_reports", workspace_id=workspace.id),
active=g.matchesPath('\/workspaces\/[A-Za-z0-9]*\/reports') active=request.url_rule.rule.startswith('/workspaces/<workspace_id>/reports')
) }} ) }}
</ul> </ul>
</nav> </nav>

View File

@ -88,7 +88,7 @@
<footer class='block-list__footer'> <footer class='block-list__footer'>
<a href='/' class='icon-link'> <a href='/' class='icon-link'>
{% module Icon('plus') %} {% module Icon('plus') %}
<span>Add another environment</span> <span class="icon-link">Add another environment</span>
</a> </a>
</footer> </footer>
</section> </section>

View File

@ -9,6 +9,24 @@
<financial inline-template v-bind:initial-data='{{ f.data|tojson }}'> <financial inline-template v-bind:initial-data='{{ f.data|tojson }}'>
<div class="col"> <div class="col">
{% if extended %}
{{ Alert('Task Order not found in EDA',
message="Since the Task Order (TO) number was not found in our system of record, EDA, please populate the additional fields in the form below.",
level='warning'
) }}
{% endif %}
{% if f.is_missing_task_order_number %}
{% set extended_url = url_for('requests.financial_verification', request_id=request_id, extended=True) %}
{{ Alert('Task Order not found in EDA',
message="We could not find your Task Order in our system of record, EDA.
Please confirm that you have entered it correctly.
<a href=\"%s\">Otherwise enter TO information manually.</a>
"|format(extended_url),
level='warning'
) }}
{% endif %}
<div class="panel"> <div class="panel">
<div class="panel__heading"> <div class="panel__heading">
@ -19,7 +37,11 @@
<div class="panel__content"> <div class="panel__content">
{% block form_action %} {% block form_action %}
<form method='POST' action="{{ url_for('requests.financial_verification', request_id=request_id) }}" autocomplete="off"> {% if extended %}
<form method='POST' action="{{ url_for('requests.financial_verification', request_id=request_id, extended=True) }}" autocomplete="off">
{% else %}
<form method='POST' action="{{ url_for('requests.financial_verification', request_id=request_id) }}" autocomplete="off">
{% endif %}
{% endblock %} {% endblock %}
{{ f.csrf_token }} {{ f.csrf_token }}
@ -35,8 +57,48 @@
<p>In order to get you access to the JEDI Cloud, we will need you to enter the details below that will help us verify and account for your Task Order.</p> <p>In order to get you access to the JEDI Cloud, we will need you to enter the details below that will help us verify and account for your Task Order.</p>
{% if extended %}
<fieldset class="form__sub-fields form__sub-fields--warning">
{{ OptionsInput(f.funding_type) }}
<template v-if="funding_type == 'OTHER'" v-cloak>
{{ TextInput(f.funding_type_other) }}
</template>
{{ TextInput(
f.clin_0001,placeholder="50,000",
validation='integer'
) }}
{{ TextInput(
f.clin_0003,placeholder="13,000",
validation='integer'
) }}
{{ TextInput(
f.clin_1001,placeholder="30,000",
validation='integer'
) }}
{{ TextInput(
f.clin_1003,placeholder="7,000",
validation='integer'
) }}
{{ TextInput(
f.clin_2001,placeholder="30,000",
validation='integer'
) }}
{{ TextInput(
f.clin_2003,placeholder="7,000",
validation='integer'
) }}
</fieldset>
{% endif %}
{{ TextInput( {{ TextInput(
f.task_order_id, f.task_order_number,
placeholder="e.g.: 1234567899C0001", placeholder="e.g.: 1234567899C0001",
tooltip="A Contracting Officer will likely be the best source for this number.", tooltip="A Contracting Officer will likely be the best source for this number.",
validation="anything" validation="anything"
@ -83,51 +145,6 @@
{{ TextInput(f.office_cor,placeholder="e.g.: WHS") }} {{ TextInput(f.office_cor,placeholder="e.g.: WHS") }}
<hr>
{{ Alert('Task Order not found in EDA',
message="Since the Task Order (TO) number was not found in our system of record, EDA, please populate the additional fields in the form below.",
level='warning'
) }}
<fieldset class="form__sub-fields form__sub-fields--warning">
{{ OptionsInput(f.funding_type) }}
<template v-if="funding_type == 'OTHER'" v-cloak>
{{ TextInput(f.funding_type_other) }}
</template>
{{ TextInput(
f.clin_0001,placeholder="50,000",
validation='integer'
) }}
{{ TextInput(
f.clin_0003,placeholder="13,000",
validation='integer'
) }}
{{ TextInput(
f.clin_1001,placeholder="30,000",
validation='integer'
) }}
{{ TextInput(
f.clin_1003,placeholder="7,000",
validation='integer'
) }}
{{ TextInput(
f.clin_2001,placeholder="30,000",
validation='integer'
) }}
{{ TextInput(
f.clin_2003,placeholder="7,000",
validation='integer'
) }}
</fieldset>
{% endautoescape %} {% endautoescape %}
{% endblock form %} {% endblock form %}

View File

@ -65,7 +65,7 @@
</div> </div>
<transition name='slide'> <transition name='slide'>
<template v-if="annualSpend > 1000000"> <template v-if="annualSpend > {{ annual_spend_threshold }}">
<fieldset class='form__sub-fields'> <fieldset class='form__sub-fields'>
<h3>Because the approximate annual spend is over $1,000,000, please answer a few additional questions.</h3> <h3>Because the approximate annual spend is over $1,000,000, please answer a few additional questions.</h3>
{{ TextInput(f.number_user_sessions, validation='integer', placeholder="0") }} {{ TextInput(f.number_user_sessions, validation='integer', placeholder="0") }}

View File

@ -143,7 +143,7 @@
</dd> </dd>
</div> </div>
{% if jedi_request and jedi_request.annual_spend > 1000000 %} {% if jedi_request and jedi_request.annual_spend > annual_spend_threshold %}
<div> <div>
<dt>Number of User Sessions</dt> <dt>Number of User Sessions</dt>

View File

@ -15,9 +15,9 @@
<h2 id="financial-verification">Financial Verification</h2> <h2 id="financial-verification">Financial Verification</h2>
<p>In order to get you access to the JEDI Cloud, we will need you to enter the details below that will help us verify and account for your Task Order.</p> <p>In order to get you access to the JEDI Cloud, we will need you to enter the details below that will help us verify and account for your Task Order.</p>
{{ f.task_order_id.label }} {{ f.task_order_number.label }}
{{ f.task_order_id(placeholder="Example: 1234567899C0001") }} {{ f.task_order_number(placeholder="Example: 1234567899C0001") }}
{% for e in f.task_order_id.errors %} {% for e in f.task_order_number.errors %}
<div class="usa-input-error-message"> <div class="usa-input-error-message">
{{ e }} {{ e }}
</div> </div>

View File

@ -4,7 +4,7 @@
{% block workspace_content %} {% block workspace_content %}
{% if not members %} {% if not workspace.members %}
{{ EmptyState( {{ EmptyState(
'There are currently no members in this Workspace.', 'There are currently no members in this Workspace.',
@ -58,7 +58,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for m in members %} {% for m in workspace.members %}
<tr> <tr>
<td><a href="/workspaces/123456/members/789/edit" class="icon-link icon-link--large">{{ m['first_name'] }} {{ m['last_name'] }}</a></td> <td><a href="/workspaces/123456/members/789/edit" class="icon-link icon-link--large">{{ m['first_name'] }} {{ m['last_name'] }}</a></td>
<td class='table-cell--shrink'>{% if m['num_projects'] == '0' %} <span class="label label--info">No Project Access</span> {% endif %}</td> <td class='table-cell--shrink'>{% if m['num_projects'] == '0' %} <span class="label label--info">No Project Access</span> {% endif %}</td>

View File

@ -0,0 +1,59 @@
{% from "components/icon.html" import Icon %}
{% from "components/text_input.html" import TextInput %}
{% from "components/tooltip.html" import Tooltip %}
{% extends "base_workspace.html" %}
{% block workspace_content %}
<new-project inline-template v-bind:initial-data='{{ form.data|tojson }}'>
<form method="POST" action="{{ url_for('workspaces.update_project', workspace_id=workspace.id) }}" >
{{ form.csrf_token }}
<div class="panel">
<div class="panel__heading panel__heading--grow">
<h1>Add a new project</h1>
{{ Tooltip(
"AT-AT allows you to organize your workspace into multiple projects, each of which may have environments.",
title="learn more"
)}}
</div>
<div class="panel__content">
{{ TextInput(form.name) }}
{{ TextInput(form.description, paragraph=True) }}
</div>
</div>
<div class="block-list project-list-item">
<header class="block-list__header">
<h2 class="block-list__title">Project Environments</h2>
{{ Tooltip(
"Each environment created within a project is an enclave of cloud resources that is logically separated from each other for increased security.",
title="learn more"
)}}
</header>
<ul>
<li v-for="(_, i) in environments" class="block-list__item">
{{ TextInput(form.environment_name) }}
<span class="icon-link icon-link--danger icon-link--vertical" v-on:click="removeEnvironment(i)">{{ Icon('x') }} Remove</span>
</li>
</ul>
<div class="block-list__footer">
<a v-on:click="addEnvironment" class="icon-link">Add another environment</a>
</div>
</div>
<div class="action-group">
<input type="submit" value="Create Project" class="usa-button usa-button-primary">
<a href="{{ url_for('workspaces.workspace_projects', workspace_id=workspace.id) }}" class="action-group__action">Cancel</a>
</div>
</div>
</form>
</new-project>
{% endblock %}

View File

@ -1,24 +1,34 @@
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% from "components/alert.html" import Alert %}
{% extends "base_workspace.html" %} {% extends "base_workspace.html" %}
{% block workspace_content %} {% block workspace_content %}
{% for project in projects %} {% if request.args.get("newWorkspace") %}
{{ Alert('Workspace created!',
message="\
<p>You are now ready to create projects and environments within the JEDI Cloud.</p>
",
level='success'
) }}
{% endif %}
{% for project in workspace.projects %}
<div class='block-list project-list-item'> <div class='block-list project-list-item'>
<header class='block-list__header'> <header class='block-list__header'>
<h2 class='block-list__title'>{{ project['name'] }} ({{ project['environments']|length }} environments)</h2> <h2 class='block-list__title'>{{ project.name }} ({{ project.environments|length }} environments)</h2>
<a class='icon-link' href='/workspaces/123456/projects/789/edit'> <a class='icon-link' href='/workspaces/123456/projects/789/edit'>
{{ Icon('edit') }} {{ Icon('edit') }}
<span>edit</span> <span>edit</span>
</a> </a>
</header> </header>
<ul> <ul>
{% for environment in project['environments'] %} {% for environment in project.environments %}
<li class='block-list__item project-list-item__environment'> <li class='block-list__item project-list-item__environment'>
<a href='/' target='_blank' rel='noopener noreferrer' class='project-list-item__environment__link'> <a href='/' target='_blank' rel='noopener noreferrer' class='project-list-item__environment__link'>
{{ Icon('link') }} {{ Icon('link') }}
<span>{{ environment["name"]}}</span> <span>{{ environment.name }}</span>
</a> </a>
<div class='project-list-item__environment__members'> <div class='project-list-item__environment__members'>

View File

@ -5,7 +5,7 @@
{% block workspace_content %} {% block workspace_content %}
{{ Alert("Funding Information & Reports for Workspace " + workspace_id, {{ Alert("Funding Information & Reports for Workspace " + workspace.name,
message="<p>On this screen you'll find detailed reporting information on this workspace. This message needs to be written better and be dismissable.</p>", message="<p>On this screen you'll find detailed reporting information on this workspace. This message needs to be written better and be dismissable.</p>",
actions=[ actions=[
{"label": "Learn More", "href": "/", "icon": "info"}, {"label": "Learn More", "href": "/", "icon": "info"},

View File

@ -11,16 +11,16 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for w in workspaces %} {% for workspace in workspaces %}
<tr> <tr>
<td> <td>
<a class='icon-link icon-link--large' href="/workspaces/{{w['task_order']['number']}}/projects">{{ w['name'] }}</a><br> <a class='icon-link icon-link--large' href="/workspaces/{{ workspace.id }}/projects">{{ workspace.name }}</a><br>
</td> </td>
<td> <td>
#{{ w['task_order']['number'] }} #{{ workspace.task_order.number }}
</td> </td>
<td> <td>
<span class="label">{{ w['user_count'] }}</span><span class='h6'>Users</span> <span class="label">{{ workspace.user_count }}</span><span class='h6'>Users</span>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -1,12 +0,0 @@
{% extends "base.html.to" %}
{% block content %}
<div class="usa-width-one-whole empty-state">
<p>There are currently no JEDI workspaces</p>
<a href="" class="usa-button">New Workspace</a>
</div>
{% end %}

View File

@ -2,6 +2,7 @@ import pytest
from atst.domain.exceptions import NotFoundError from atst.domain.exceptions import NotFoundError
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.eda_client import MockEDAClient
from tests.factories import TaskOrderFactory from tests.factories import TaskOrderFactory
@ -13,6 +14,19 @@ def test_can_get_task_order():
assert to.id == to.id assert to.id == to.id
def test_nonexistent_task_order_raises(): def test_can_get_task_order_from_eda(monkeypatch):
monkeypatch.setattr("atst.domain.task_orders.TaskOrders._client", lambda: MockEDAClient())
to = TaskOrders.get(MockEDAClient.MOCK_CONTRACT_NUMBER)
assert to.number == MockEDAClient.MOCK_CONTRACT_NUMBER
def test_nonexistent_task_order_raises_without_client():
with pytest.raises(NotFoundError): with pytest.raises(NotFoundError):
TaskOrders.get("some fake number") TaskOrders.get("some fake number")
def test_nonexistent_task_order_raises_with_client(monkeypatch):
monkeypatch.setattr("atst.domain.task_orders.TaskOrders._client", lambda: MockEDAClient())
with pytest.raises(NotFoundError):
TaskOrders.get("some other fake numer")

View File

@ -1,32 +1,30 @@
from uuid import uuid4
from atst.domain.workspace_users import WorkspaceUsers from atst.domain.workspace_users import WorkspaceUsers
from atst.domain.users import Users from atst.domain.users import Users
from tests.factories import WorkspaceFactory
def test_can_create_new_workspace_user(): def test_can_create_new_workspace_user():
workspace_id = uuid4() workspace = WorkspaceFactory.create()
user = Users.create("developer") new_user = Users.create("developer")
workspace_user_dicts = [{"id": user.id, "workspace_role": "owner"}] workspace_user_dicts = [{"id": new_user.id, "workspace_role": "owner"}]
workspace_users = WorkspaceUsers.add_many(workspace.id, workspace_user_dicts)
workspace_users = WorkspaceUsers.add_many(workspace_id, workspace_user_dicts) assert workspace_users[0].user.id == new_user.id
assert workspace_users[0].user.id == user.id
assert workspace_users[0].user.atat_role.name == "developer" assert workspace_users[0].user.atat_role.name == "developer"
assert workspace_users[0].workspace_role.role.name == "owner" assert workspace_users[0].workspace_role.role.name == "owner"
def test_can_update_existing_workspace_user(): def test_can_update_existing_workspace_user():
workspace_id = uuid4() workspace = WorkspaceFactory.create()
user = Users.create("developer") new_user = Users.create("developer")
WorkspaceUsers.add_many( WorkspaceUsers.add_many(
workspace_id, [{"id": user.id, "workspace_role": "owner"}] workspace.id, [{"id": new_user.id, "workspace_role": "owner"}]
) )
workspace_users = WorkspaceUsers.add_many( workspace_users = WorkspaceUsers.add_many(
workspace_id, [{"id": user.id, "workspace_role": "developer"}] workspace.id, [{"id": new_user.id, "workspace_role": "developer"}]
) )
assert workspace_users[0].user.id == user.id assert workspace_users[0].user.id == new_user.id
assert workspace_users[0].workspace_role.role.name == "developer" assert workspace_users[0].workspace_role.role.name == "developer"

View File

@ -0,0 +1,89 @@
import pytest
from uuid import uuid4
from atst.domain.exceptions import NotFoundError, UnauthorizedError
from atst.domain.workspaces import Workspaces
from atst.domain.workspace_users import WorkspaceUsers
from tests.factories import WorkspaceFactory, RequestFactory, UserFactory
def test_can_create_workspace():
request = RequestFactory.create()
workspace = Workspaces.create(request, name="frugal-whale")
assert workspace.name == "frugal-whale"
assert workspace.request == request
def test_default_workspace_name_is_request_id():
request = RequestFactory.create()
workspace = Workspaces.create(request)
assert workspace.name == str(request.id)
def test_get_nonexistent_workspace_raises():
with pytest.raises(NotFoundError):
Workspaces.get(UserFactory.build(), uuid4())
def test_can_get_workspace_by_request():
workspace = WorkspaceFactory.create()
found = Workspaces.get_by_request(workspace.request)
assert workspace == found
def test_creating_workspace_adds_owner():
user = UserFactory.create()
request = RequestFactory.create(creator=user)
workspace = Workspaces.create(request)
assert workspace.roles[0].user == user
def test_workspace_has_timestamps():
request = RequestFactory.create()
workspace = Workspaces.create(request)
assert workspace.time_created == workspace.time_updated
def test_workspaces_get_ensures_user_is_in_workspace():
owner = UserFactory.create()
outside_user = UserFactory.create()
workspace = Workspaces.create(RequestFactory.create(creator=owner))
workspace_ = Workspaces.get(owner, workspace.id)
assert workspace_ == workspace
with pytest.raises(UnauthorizedError):
Workspaces.get(outside_user, workspace.id)
def test_workspaces_get_many_with_no_workspaces():
workspaces = Workspaces.get_many(UserFactory.build())
assert workspaces == []
def test_workspaces_get_many_returns_a_users_workspaces():
user = UserFactory.create()
users_workspace = Workspaces.create(RequestFactory.create(creator=user))
# random workspace
Workspaces.create(RequestFactory.create())
assert Workspaces.get_many(user) == [users_workspace]
def test_get_for_update_allows_owner():
owner = UserFactory.create()
workspace = Workspaces.create(RequestFactory.create(creator=owner))
Workspaces.get_for_update(owner, workspace.id)
def test_get_for_update_blocks_developer():
owner = UserFactory.create()
developer = UserFactory.create()
workspace = Workspaces.create(RequestFactory.create(creator=owner))
WorkspaceUsers.add(developer, workspace.id, "developer")
with pytest.raises(UnauthorizedError):
Workspaces.get_for_update(developer, workspace.id)

View File

@ -10,6 +10,7 @@ from atst.models.pe_number import PENumber
from atst.models.task_order import TaskOrder from atst.models.task_order import TaskOrder
from atst.models.user import User from atst.models.user import User
from atst.models.role import Role from atst.models.role import Role
from atst.models.workspace import Workspace
from atst.models.request_status_event import RequestStatusEvent from atst.models.request_status_event import RequestStatusEvent
from atst.domain.roles import Roles from atst.domain.roles import Roles
@ -102,3 +103,12 @@ class PENumberFactory(factory.alchemy.SQLAlchemyModelFactory):
class TaskOrderFactory(factory.alchemy.SQLAlchemyModelFactory): class TaskOrderFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta: class Meta:
model = TaskOrder model = TaskOrder
class WorkspaceFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = Workspace
request = factory.SubFactory(RequestFactory)
# name it the same as the request ID by default
name = factory.LazyAttribute(lambda w: w.request.id)

View File

@ -1,6 +1,7 @@
import pytest import pytest
from atst.forms.financial import suggest_pe_id, FinancialForm from atst.forms.financial import suggest_pe_id, FinancialForm, ExtendedFinancialForm
from atst.eda_client import MockEDAClient
@pytest.mark.parametrize("input_,expected", [ @pytest.mark.parametrize("input_,expected", [
@ -18,7 +19,7 @@ def test_funding_type_other_not_required_if_funding_type_is_not_other():
form_data = { form_data = {
"funding_type": "PROC" "funding_type": "PROC"
} }
form = FinancialForm(data=form_data) form = ExtendedFinancialForm(data=form_data)
form.validate() form.validate()
assert "funding_type_other" not in form.errors assert "funding_type_other" not in form.errors
@ -27,7 +28,7 @@ def test_funding_type_other_required_if_funding_type_is_other():
form_data = { form_data = {
"funding_type": "OTHER" "funding_type": "OTHER"
} }
form = FinancialForm(data=form_data) form = ExtendedFinancialForm(data=form_data)
form.validate() form.validate()
assert "funding_type_other" in form.errors assert "funding_type_other" in form.errors
@ -67,3 +68,16 @@ def test_ba_code_validation(input_, expected):
is_valid = "ba_code" not in form.errors is_valid = "ba_code" not in form.errors
assert is_valid == expected assert is_valid == expected
def test_task_order_number_validation(monkeypatch):
monkeypatch.setattr("atst.domain.task_orders.TaskOrders._client", lambda: MockEDAClient())
form_invalid = FinancialForm(data={"task_order_number": "1234"})
form_invalid.validate()
assert "task_order_number" in form_invalid.errors
form_valid = FinancialForm(data={"task_order_number": MockEDAClient.MOCK_CONTRACT_NUMBER}, eda_client=MockEDAClient())
form_valid.validate()
assert "task_order_number" not in form_valid.errors

View File

@ -1,15 +1,17 @@
import re
import pytest
import urllib import urllib
from flask import url_for
from atst.eda_client import MockEDAClient
from tests.mocks import MOCK_REQUEST, MOCK_USER from tests.mocks import MOCK_REQUEST, MOCK_USER
from tests.factories import PENumberFactory from tests.factories import PENumberFactory, RequestFactory
class TestPENumberInForm: class TestPENumberInForm:
required_data = { required_data = {
"pe_id": "123", "pe_id": "123",
"task_order_id": "1234567899C0001", "task_order_number": MockEDAClient.MOCK_CONTRACT_NUMBER,
"fname_co": "Contracting", "fname_co": "Contracting",
"lname_co": "Officer", "lname_co": "Officer",
"email_co": "jane@mail.mil", "email_co": "jane@mail.mil",
@ -18,6 +20,11 @@ class TestPENumberInForm:
"lname_cor": "Representative", "lname_cor": "Representative",
"email_cor": "jane@mail.mil", "email_cor": "jane@mail.mil",
"office_cor": "WHS", "office_cor": "WHS",
"uii_ids": "1234",
"treasury_code": "00123456",
"ba_code": "024A"
}
extended_data = {
"funding_type": "RDTE", "funding_type": "RDTE",
"funding_type_other": "other", "funding_type_other": "other",
"clin_0001": "50,000", "clin_0001": "50,000",
@ -30,12 +37,15 @@ class TestPENumberInForm:
def _set_monkeypatches(self, monkeypatch): def _set_monkeypatches(self, monkeypatch):
monkeypatch.setattr("atst.forms.financial.FinancialForm.validate", lambda s: True) monkeypatch.setattr("atst.forms.financial.FinancialForm.validate", lambda s: True)
monkeypatch.setattr("atst.domain.requests.Requests.get", lambda i: MOCK_REQUEST)
monkeypatch.setattr("atst.domain.auth.get_current_user", lambda *args: MOCK_USER) monkeypatch.setattr("atst.domain.auth.get_current_user", lambda *args: MOCK_USER)
def submit_data(self, client, data): def submit_data(self, client, data, extended=False):
request = RequestFactory.create(body=MOCK_REQUEST.body)
url_kwargs = {"request_id": request.id}
if extended:
url_kwargs["extended"] = True
response = client.post( response = client.post(
"/requests/verify/{}".format(MOCK_REQUEST.id), url_for("requests.financial_verification", **url_kwargs),
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
data=urllib.parse.urlencode(data), data=urllib.parse.urlencode(data),
follow_redirects=False, follow_redirects=False,
@ -59,7 +69,7 @@ class TestPENumberInForm:
response = self.submit_data(client, data) response = self.submit_data(client, data)
assert response.status_code == 302 assert response.status_code == 302
assert "/requests/financial_verification_submitted" in response.headers.get("Location") assert "/workspaces" in response.headers.get("Location")
def test_submit_request_form_with_new_valid_pe_id(self, monkeypatch, client): def test_submit_request_form_with_new_valid_pe_id(self, monkeypatch, client):
self._set_monkeypatches(monkeypatch) self._set_monkeypatches(monkeypatch)
@ -71,7 +81,7 @@ class TestPENumberInForm:
response = self.submit_data(client, data) response = self.submit_data(client, data)
assert response.status_code == 302 assert response.status_code == 302
assert "/requests/financial_verification_submitted" in response.headers.get("Location") assert "/workspaces" in response.headers.get("Location")
def test_submit_request_form_with_missing_pe_id(self, monkeypatch, client): def test_submit_request_form_with_missing_pe_id(self, monkeypatch, client):
self._set_monkeypatches(monkeypatch) self._set_monkeypatches(monkeypatch)
@ -83,3 +93,40 @@ class TestPENumberInForm:
assert "There were some errors" in response.data.decode() assert "There were some errors" in response.data.decode()
assert response.status_code == 200 assert response.status_code == 200
def test_submit_financial_form_with_invalid_task_order(self, monkeypatch, user_session, client):
monkeypatch.setattr("atst.domain.requests.Requests.get", lambda i: MOCK_REQUEST)
user_session()
data = dict(self.required_data)
data['pe_id'] = MOCK_REQUEST.body['financial_verification']['pe_id']
data['task_order_number'] = '1234'
response = self.submit_data(client, data)
assert "enter TO information manually" in response.data.decode()
def test_submit_financial_form_with_valid_task_order(self, monkeypatch, user_session, client):
monkeypatch.setattr("atst.domain.requests.Requests.get", lambda i: MOCK_REQUEST)
user_session()
data = dict(self.required_data)
data['pe_id'] = MOCK_REQUEST.body['financial_verification']['pe_id']
data['task_order_number'] = MockEDAClient.MOCK_CONTRACT_NUMBER
response = self.submit_data(client, data)
assert "enter TO information manually" not in response.data.decode()
def test_submit_extended_financial_form(self, monkeypatch, user_session, client):
monkeypatch.setattr("atst.domain.requests.Requests.get", lambda i: MOCK_REQUEST)
user_session()
data = { **self.required_data, **self.extended_data }
data['pe_id'] = MOCK_REQUEST.body['financial_verification']['pe_id']
data['task_order_number'] = "1234567"
response = self.submit_data(client, data, extended=True)
assert response.status_code == 302
assert "/workspaces" in response.headers.get("Location")

View File

@ -2,6 +2,7 @@ import pytest
from flask import session, url_for from flask import session, url_for
from .mocks import DOD_SDN_INFO, DOD_SDN, FIXTURE_EMAIL_ADDRESS from .mocks import DOD_SDN_INFO, DOD_SDN, FIXTURE_EMAIL_ADDRESS
from atst.domain.users import Users from atst.domain.users import Users
from atst.domain.roles import Roles
from atst.domain.exceptions import NotFoundError from atst.domain.exceptions import NotFoundError
from .factories import UserFactory from .factories import UserFactory
@ -13,7 +14,7 @@ def _fetch_user_info(c, t):
return MOCK_USER return MOCK_USER
def test_successful_login_redirect(client, monkeypatch): def test_successful_login_redirect_non_ccpo(client, monkeypatch):
monkeypatch.setattr("atst.domain.authnid.AuthenticationContext.authenticate", lambda *args: True) monkeypatch.setattr("atst.domain.authnid.AuthenticationContext.authenticate", lambda *args: True)
monkeypatch.setattr("atst.domain.authnid.AuthenticationContext.get_user", lambda *args: UserFactory.create()) monkeypatch.setattr("atst.domain.authnid.AuthenticationContext.get_user", lambda *args: UserFactory.create())
@ -26,6 +27,24 @@ def test_successful_login_redirect(client, monkeypatch):
}, },
) )
assert resp.status_code == 302
assert "requests" in resp.headers["Location"]
assert session["user_id"]
def test_successful_login_redirect_ccpo(client, monkeypatch):
monkeypatch.setattr("atst.domain.authnid.AuthenticationContext.authenticate", lambda *args: True)
role = Roles.get("ccpo")
monkeypatch.setattr("atst.domain.authnid.AuthenticationContext.get_user", lambda *args: UserFactory.create(atat_role=role))
resp = client.get(
"/login-redirect",
environ_base={
"HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS",
"HTTP_X_SSL_CLIENT_S_DN": "",
"HTTP_X_SSL_CLIENT_CERT": "",
},
)
assert resp.status_code == 302 assert resp.status_code == 302
assert "home" in resp.headers["Location"] assert "home" in resp.headers["Location"]
assert session["user_id"] assert session["user_id"]
@ -90,7 +109,7 @@ def test_crl_validation_on_login(client):
}, },
) )
assert resp.status_code == 302 assert resp.status_code == 302
assert "home" in resp.headers["Location"] assert "requests" in resp.headers["Location"]
assert session["user_id"] assert session["user_id"]