diff --git a/.gitignore b/.gitignore index 6afb33d1..7f2755cc 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ config/dev.ini # CRLs /crl /crl-tmp +*.bk diff --git a/Pipfile b/Pipfile index 1312960b..fef290df 100644 --- a/Pipfile +++ b/Pipfile @@ -29,6 +29,7 @@ black = "*" pytest-watch = "*" factory-boy = "*" pytest-flask = "*" +pytest-env = "*" [requires] python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index 7f8a00cb..8a19a1a2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "5fc8273838354406366b401529a6f512a73ac6a8ecea6699afa4ab7b4996bf13" + "sha256": "41ad134816dae388385cfb15105e0eca436b25791ec4fbf67a2b36c4ae8056bd" }, "pipfile-spec": 6, "requires": { @@ -33,10 +33,10 @@ }, "certifi": { "hashes": [ - "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7", - "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0" + "sha256:4c1d68a1408dd090d2f3a869aa94c3947cc1d967821d1ed303208c9f41f0f2f4", + "sha256:b6e8b28b2b7e771a41ecdd12d4d43262ecab52adebbafa42c77d6b57fb6ad3a4" ], - "version": "==2018.4.16" + "version": "==2018.8.13" }, "cffi": { "hashes": [ @@ -91,27 +91,27 @@ }, "cryptography": { "hashes": [ - "sha256:21af753934f2f6d1a10fe8f4c0a64315af209ef6adeaee63ca349797d747d687", - "sha256:27bb401a20a838d6d0ea380f08c6ead3ccd8c9d8a0232dc9adcc0e4994576a66", - "sha256:29720c4253263cff9aea64585adbbe85013ba647f6e98367efff9db2d7193ded", - "sha256:2a35b7570d8f247889784010aac8b384fd2e4a47b33e15c4a60b45a7c1944120", - "sha256:42c531a6a354407f42ee07fda5c2c0dc822cf6d52744949c182f2b295fbd4183", - "sha256:5eb86f03f9c4f0ac2336ac5431271072ddf7ecc76b338e26366732cfac58aa19", - "sha256:67f7f57eae8dede577f3f7775957f5bec93edd6bdb6ce597bb5b28e1bdf3d4fb", - "sha256:6ec84edcbc966ae460560a51a90046503ff0b5b66157a9efc61515c68059f6c8", - "sha256:7ba834564daef87557e7fcd35c3c3183a4147b0b3a57314e53317360b9b201b3", - "sha256:7d7f084cbe1fdb82be5a0545062b59b1ad3637bc5a48612ac2eb428ff31b31ea", - "sha256:82409f5150e529d699e5c33fa8fd85e965104db03bc564f5f4b6a9199e591f7c", - "sha256:87d092a7c2a44e5f7414ab02fb4145723ebba411425e1a99773531dd4c0e9b8d", - "sha256:8c56ef989342e42b9fcaba7c74b446f0cc9bed546dd00034fa7ad66fc00307ef", - "sha256:9449f5d4d7c516a6118fa9210c4a00f34384cb1d2028672100ee0c6cce49d7f6", - "sha256:bc2301170986ad82d9349a91eb8884e0e191209c45f5541b16aa7c0cfb135978", - "sha256:c132bab45d4bd0fff1d3fe294d92b0a6eb8404e93337b3127bdec9f21de117e6", - "sha256:c3d945b7b577f07a477700f618f46cbc287af3a9222cd73035c6ef527ef2c363", - "sha256:cee18beb4c807b5c0b178f4fa2fae03cef9d51821a358c6890f8b23465b7e5d2", - "sha256:d01dfc5c2b3495184f683574e03c70022674ca9a7be88589c5aba130d835ea90" + "sha256:02602e1672b62e803e08617ec286041cc453e8d43f093a5f4162095506bc0beb", + "sha256:10b48e848e1edb93c1d3b797c83c72b4c387ab0eb4330aaa26da8049a6cbede0", + "sha256:17db09db9d7c5de130023657be42689d1a5f60502a14f6f745f6f65a6b8195c0", + "sha256:227da3a896df1106b1a69b1e319dce218fa04395e8cc78be7e31ca94c21254bc", + "sha256:2cbaa03ac677db6c821dac3f4cdfd1461a32d0615847eedbb0df54bb7802e1f7", + "sha256:31db8febfc768e4b4bd826750a70c79c99ea423f4697d1dab764eb9f9f849519", + "sha256:4a510d268e55e2e067715d728e4ca6cd26a8e9f1f3d174faf88e6f2cb6b6c395", + "sha256:6a88d9004310a198c474d8a822ee96a6dd6c01efe66facdf17cb692512ae5bc0", + "sha256:76936ec70a9b72eb8c58314c38c55a0336a2b36de0c7ee8fb874a4547cadbd39", + "sha256:7e3b4aecc4040928efa8a7cdaf074e868af32c58ffc9bb77e7bf2c1a16783286", + "sha256:8168bcb08403ef144ff1fb880d416f49e2728101d02aaadfe9645883222c0aa5", + "sha256:8229ceb79a1792823d87779959184a1bf95768e9248c93ae9f97c7a2f60376a1", + "sha256:8a19e9f2fe69f6a44a5c156968d9fc8df56d09798d0c6a34ccc373bb186cee86", + "sha256:8d10113ca826a4c29d5b85b2c4e045ffa8bad74fb525ee0eceb1d38d4c70dfd6", + "sha256:be495b8ec5a939a7605274b6e59fbc35e76f5ad814ae010eb679529671c9e119", + "sha256:dc2d3f3b1548f4d11786616cf0f4415e25b0fbecb8a1d2cd8c07568f13fdde38", + "sha256:e4aecdd9d5a3d06c337894c9a6e2961898d3f64fe54ca920a72234a3de0f9cb3", + "sha256:e79ab4485b99eacb2166f3212218dd858258f374855e1568f728462b0e6ee0d9", + "sha256:f995d3667301e1754c57b04e0bae6f0fa9d710697a9f8d6712e8cca02550910f" ], - "version": "==2.3" + "version": "==2.3.1" }, "flask": { "hashes": [ @@ -271,7 +271,7 @@ "sha256:1d936da41ee06216d89fdc7ead1ee9a5da2811a8787515a976b646e110c3f622", "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" }, "redis": { @@ -299,10 +299,10 @@ }, "sqlalchemy": { "hashes": [ - "sha256:72325e67fb85f6e9ad304c603d83626d1df684fdf0c7ab1f0352e71feeab69d8" + "sha256:ef6569ad403520ee13e180e1bfd6ed71a0254192a934ec1dbd3dbf48f4aa9524" ], "index": "pypi", - "version": "==1.2.10" + "version": "==1.2.11" }, "unipath": { "hashes": [ @@ -317,7 +317,7 @@ "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", "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" }, "webassets": { @@ -350,14 +350,6 @@ ], "version": "==1.4.3" }, - "appnope": { - "hashes": [ - "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0", - "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71" - ], - "markers": "sys_platform == 'darwin'", - "version": "==0.1.0" - }, "argh": { "hashes": [ "sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3", @@ -367,10 +359,10 @@ }, "astroid": { "hashes": [ - "sha256:a48b57ede295c3188ef5c84273bc2a8eadc46e4cbb001eae0d49fb5d1fabbb19", - "sha256:d066cdeec5faeb51a4be5010da612680653d844b57afd86a5c8315f2f801b4cc" + "sha256:292fa429e69d60e4161e7612cb7cc8fa3609e2e309f80c224d93a76d5e7b58be", + "sha256:c7013d119ec95eb626f7a2011f0b63d0c9a095df9ad06d8507b37084eada1a8d" ], - "version": "==2.0.2" + "version": "==2.0.4" }, "atomicwrites": { "hashes": [ @@ -395,11 +387,11 @@ }, "bandit": { "hashes": [ - "sha256:cb977045497f83ec3a02616973ab845c829cdab8144ce2e757fe031104a9abd4", - "sha256:de4cc19d6ba32d6f542c6a1ddadb4404571347d83ef1ed1e7afb7d0b38e0c25b" + "sha256:45bf1b361004e861e5b423b36ff5c700d21442753c841013c87f14a4639b1d74", + "sha256:a3aa04802194ec1fd290849e02b915824f9c3234623d7dcea6a33b1605ddb0ac" ], "index": "pypi", - "version": "==1.4.0" + "version": "==1.5.0" }, "black": { "hashes": [ @@ -446,11 +438,11 @@ }, "faker": { "hashes": [ - "sha256:0e9a1227a3a0f3297a485715e72ee6eb77081b17b629367042b586e38c03c867", - "sha256:b4840807a94a3bad0217d6ed3f9b65a1cc6e1db1c99e1184673056ae2c0a4c4d" + "sha256:ea7cfd3aeb1544732d08bd9cfba40c5b78e3a91e17b1a0698ab81bfc5554c628", + "sha256:f6d67f04abfb2b4bea7afc7fa6c18cf4c523a67956e455668be9ae42bccc21ad" ], - "markers": "python_version != '3.1.*' and python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.0.*'", - "version": "==0.8.17" + "markers": "python_version != '3.2.*' and python_version != '3.0.*' and python_version != '3.1.*' and python_version >= '2.7'", + "version": "==0.9.0" }, "flask": { "hashes": [ @@ -502,7 +494,7 @@ "sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8", "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" }, "itsdangerous": { @@ -620,7 +612,7 @@ "sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1", "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" }, "prompt-toolkit": { @@ -643,7 +635,7 @@ "sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7", "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" }, "pygments": { @@ -663,11 +655,18 @@ }, "pytest": { "hashes": [ - "sha256:86a8dbf407e437351cef4dba46736e9c5a6e3c3ac71b2e942209748e76ff2086", - "sha256:e74466e97ac14582a8188ff4c53e6cc3810315f342f6096899332ae864c1d432" + "sha256:3459a123ad5532852d36f6f4501dfe1acf4af1dd9541834a164666aa40395b02", + "sha256:96bfd45dbe863b447a3054145cd78a9d7f31475d2bce6111b133c0cc4f305118" ], "index": "pypi", - "version": "==3.7.1" + "version": "==3.7.2" + }, + "pytest-env": { + "hashes": [ + "sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2" + ], + "index": "pypi", + "version": "==0.6.2" }, "pytest-flask": { "hashes": [ @@ -748,42 +747,6 @@ ], "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": { "hashes": [ "sha256:7e65882adb7746039b6f3876ee174952f8eaaa34491ba34333ddf1fe35de4162" diff --git a/README.md b/README.md index e07d91ea..18744a33 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,36 @@ This is the user-facing web application for ATAT. ## Installation -### Requirements -See the [scriptz](https://github.com/dod-ccpo/scriptz) repository for the shared -requirements and guidelines for all ATAT applications. +### System Requirements +ATST uses the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) +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 -and running on the default port of 5432. +Before running the setup scripts, a couple of dependencies need to be installed +locally: -ATST also requires a redis instance for session management. Have redis installed and -running on the default port of 6379. +* `python` == 3.6 + 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 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 +After running `script/dev_server`, the application is available at +[`http://localhost:8000`](http://localhost:8000). + + ### Users There are currently six mock users for development: @@ -73,7 +98,11 @@ There are currently six mock users for development: - Dominick - Erica -To log in as one of them, navigate to `/login-dev?username=`. For example `/login-dev?username=amanda`. +To log in as one of them, navigate to `/login-dev?username=`. +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 diff --git a/alembic/versions/4be312655ceb_add_workspaces_table.py b/alembic/versions/4be312655ceb_add_workspaces_table.py new file mode 100644 index 00000000..6d0ba7d9 --- /dev/null +++ b/alembic/versions/4be312655ceb_add_workspaces_table.py @@ -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 ### diff --git a/alembic/versions/a2b499a1dd62_workspace_timestamps.py b/alembic/versions/a2b499a1dd62_workspace_timestamps.py new file mode 100644 index 00000000..f7c3e05a --- /dev/null +++ b/alembic/versions/a2b499a1dd62_workspace_timestamps.py @@ -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 ### diff --git a/alembic/versions/f064247f2988_projects_and_environments.py b/alembic/versions/f064247f2988_projects_and_environments.py new file mode 100644 index 00000000..5efa590b --- /dev/null +++ b/alembic/versions/f064247f2988_projects_and_environments.py @@ -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 ### diff --git a/alembic/versions/f36f130622b9_add_workspace_role_workspace_id_fk.py b/alembic/versions/f36f130622b9_add_workspace_role_workspace_id_fk.py new file mode 100644 index 00000000..aef41c19 --- /dev/null +++ b/alembic/versions/f36f130622b9_add_workspace_role_workspace_id_fk.py @@ -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 ### diff --git a/alembic/versions/f549c7cee17c_remove_workspaces_task_order_association.py b/alembic/versions/f549c7cee17c_remove_workspaces_task_order_association.py new file mode 100644 index 00000000..7bf2069e --- /dev/null +++ b/alembic/versions/f549c7cee17c_remove_workspaces_task_order_association.py @@ -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 ### diff --git a/atst/app.py b/atst/app.py index 99279958..7c74ebd0 100644 --- a/atst/app.py +++ b/atst/app.py @@ -18,6 +18,7 @@ from atst.routes.dev import bp as dev_routes from atst.routes.errors import make_error_pages from atst.domain.authnid.crl import CRLCache from atst.domain.auth import apply_authentication +from atst.eda_client import MockEDAClient ENV = os.getenv("FLASK_ENV", "dev") @@ -41,6 +42,7 @@ def make_app(config): make_flask_callbacks(app) make_crl_validator(app) register_filters(app) + make_eda_client(app) db.init_app(app) csrf.init_app(app) @@ -62,11 +64,6 @@ def make_app(config): def make_flask_callbacks(app): @app.before_request 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.matchesPath = lambda href: re.match("^" + href, request.path) g.modal = request.args.get("modal", None) @@ -139,3 +136,5 @@ def make_crl_validator(app): crl_locations.append(filename.absolute()) app.crl_cache = CRLCache(app.config["CA_CHAIN"], crl_locations, logger=app.logger) +def make_eda_client(app): + app.eda_client = MockEDAClient() diff --git a/atst/domain/authz.py b/atst/domain/authz.py new file mode 100644 index 00000000..506970b8 --- /dev/null +++ b/atst/domain/authz.py @@ -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 diff --git a/atst/domain/environments.py b/atst/domain/environments.py new file mode 100644 index 00000000..90755052 --- /dev/null +++ b/atst/domain/environments.py @@ -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 + diff --git a/atst/domain/projects.py b/atst/domain/projects.py new file mode 100644 index 00000000..16e0fc4c --- /dev/null +++ b/atst/domain/projects.py @@ -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 diff --git a/atst/domain/requests.py b/atst/domain/requests.py index a1102f92..4f956b6c 100644 --- a/atst/domain/requests.py +++ b/atst/domain/requests.py @@ -6,6 +6,7 @@ from sqlalchemy.orm.attributes import flag_modified from atst.models.request import Request from atst.models.request_status_event import RequestStatusEvent, RequestStatus +from atst.domain.workspaces import Workspaces from atst.database import db from .exceptions import NotFoundError @@ -31,6 +32,7 @@ def deep_merge(source, destination: dict): class Requests(object): AUTO_APPROVE_THRESHOLD = 1000000 + ANNUAL_SPEND_THRESHOLD = 1000000 @classmethod def create(cls, creator, body): @@ -114,6 +116,18 @@ class Requests(object): db.session.add(request) 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 def set_status(cls, request: Request, status: RequestStatus): status_event = RequestStatusEvent(new_status=status) diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 5b507f53..fbc587a1 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -1,4 +1,5 @@ from sqlalchemy.orm.exc import NoResultFound +from flask import current_app as app from atst.database import db from atst.models.task_order import TaskOrder @@ -8,12 +9,36 @@ from .exceptions import NotFoundError class TaskOrders(object): @classmethod - def get(self, order_number): + def get(cls, order_number): try: task_order = ( db.session.query(TaskOrder).filter_by(number=order_number).one() ) 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 + + @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 diff --git a/atst/domain/workspace_users.py b/atst/domain/workspace_users.py index 7e217c38..e7e51077 100644 --- a/atst/domain/workspace_users.py +++ b/atst/domain/workspace_users.py @@ -21,7 +21,8 @@ class WorkspaceUsers(object): try: workspace_role = ( - WorkspaceRole.query.join(User) + db.session.query(WorkspaceRole) + .join(User) .filter(User.id == user_id, WorkspaceRole.workspace_id == workspace_id) .one() ) @@ -30,6 +31,29 @@ class WorkspaceUsers(object): 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 def add_many(cls, workspace_id, workspace_user_dicts): workspace_users = [] diff --git a/atst/domain/workspaces.py b/atst/domain/workspaces.py index 6ec0b856..dab03302 100644 --- a/atst/domain/workspaces.py +++ b/atst/domain/workspaces.py @@ -1,90 +1,71 @@ -class Projects(object): - def __init__(self): - pass +from sqlalchemy.orm.exc import NoResultFound - def create(self, creator_id, body): - pass - - def get(self, project_id): - pass - - def get_many(self, workspace_id): - 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 +from atst.database import db +from atst.models.workspace import Workspace +from atst.models.workspace_role import WorkspaceRole +from atst.domain.exceptions import NotFoundError, UnauthorizedError +from atst.domain.roles import Roles +from atst.domain.authz import Authorization +from atst.models.permissions import Permissions -class Members(object): - def __init__(self): - pass +class Workspaces(object): + @classmethod + 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): - pass + db.session.add(workspace) + db.session.commit() - def get(self, request_id): - pass + return workspace - def get_many(self, workspace_id): - return [ - { - "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", - }, - ] + @classmethod + def get(cls, user, workspace_id): + try: + workspace = db.session.query(Workspace).filter_by(id=workspace_id).one() + except NoResultFound: + raise NotFoundError("workspace") - def update(self, request_id, request_delta): - pass + if not Authorization.is_in_workspace(user, workspace): + 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 diff --git a/atst/eda_client.py b/atst/eda_client.py index e181f245..82c01217 100644 --- a/atst/eda_client.py +++ b/atst/eda_client.py @@ -71,8 +71,10 @@ class MockEDAClient(EDAClientBase): }, ] + MOCK_CONTRACT_NUMBER = "DCA10096D0052" + 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 { "aco_mod": "01", "admin_dodaac": None, diff --git a/atst/forms/financial.py b/atst/forms/financial.py index 42a29d39..e989d822 100644 --- a/atst/forms/financial.py +++ b/atst/forms/financial.py @@ -1,10 +1,11 @@ import re from wtforms.fields.html5 import EmailField 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.pe_numbers import PENumbers +from atst.domain.task_orders import TaskOrders from .fields import NewlineListField, SelectField from .forms import ValidatedForm @@ -57,12 +58,7 @@ def validate_pe_id(field, existing_request): return True -class FinancialForm(ValidatedForm): - def validate(self, *args, **kwargs): - if self.funding_type.data == "OTHER": - self.funding_type_other.validators.append(Required()) - return super().validate(*args, **kwargs) - +class BaseFinancialForm(ValidatedForm): def reset(self): """ 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) 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", 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()] @@ -117,6 +117,25 @@ class FinancialForm(ValidatedForm): "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( description="What is the source of funding?", choices=[ diff --git a/atst/forms/new_project.py b/atst/forms/new_project.py new file mode 100644 index 00000000..1552a1ab --- /dev/null +++ b/atst/forms/new_project.py @@ -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") diff --git a/atst/models/__init__.py b/atst/models/__init__.py index 1d6daae1..9accc290 100644 --- a/atst/models/__init__.py +++ b/atst/models/__init__.py @@ -10,3 +10,6 @@ from .user import User from .workspace_role import WorkspaceRole from .pe_number import PENumber from .task_order import TaskOrder +from .workspace import Workspace +from .project import Project +from .environment import Environment diff --git a/atst/models/environment.py b/atst/models/environment.py new file mode 100644 index 00000000..8e812635 --- /dev/null +++ b/atst/models/environment.py @@ -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") diff --git a/atst/models/mixins.py b/atst/models/mixins.py new file mode 100644 index 00000000..467ee172 --- /dev/null +++ b/atst/models/mixins.py @@ -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()) + diff --git a/atst/models/project.py b/atst/models/project.py new file mode 100644 index 00000000..46562e82 --- /dev/null +++ b/atst/models/project.py @@ -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") diff --git a/atst/models/workspace.py b/atst/models/workspace.py new file mode 100644 index 00000000..fd8f981e --- /dev/null +++ b/atst/models/workspace.py @@ -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 diff --git a/atst/models/workspace_role.py b/atst/models/workspace_role.py index 86970e0b..be238866 100644 --- a/atst/models/workspace_role.py +++ b/atst/models/workspace_role.py @@ -10,11 +10,14 @@ class WorkspaceRole(Base): __tablename__ = "workspace_role" 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")) - user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) role = relationship("Role") + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + Index( "workspace_role_user_workspace", diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py index 68c83437..19168870 100644 --- a/atst/routes/__init__.py +++ b/atst/routes/__init__.py @@ -46,7 +46,10 @@ def login_redirect(): user = auth_context.get_user() 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): diff --git a/atst/routes/dev.py b/atst/routes/dev.py index f66e3f08..554be6fb 100644 --- a/atst/routes/dev.py +++ b/atst/routes/dev.py @@ -61,4 +61,8 @@ def login_dev(): email=user_data["email"] ) 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")) diff --git a/atst/routes/requests/__init__.py b/atst/routes/requests/__init__.py index 5a5a978f..aea193b7 100644 --- a/atst/routes/requests/__init__.py +++ b/atst/routes/requests/__init__.py @@ -1,7 +1,13 @@ from flask import Blueprint +from atst.domain.requests import Requests + requests_bp = Blueprint("requests", __name__) from . import index from . import requests_form from . import financial_verification + +@requests_bp.context_processor +def annual_spend_threshold(): + return { "annual_spend_threshold": Requests.ANNUAL_SPEND_THRESHOLD } diff --git a/atst/routes/requests/financial_verification.py b/atst/routes/requests/financial_verification.py index 966a9680..c16c6e10 100644 --- a/atst/routes/requests/financial_verification.py +++ b/atst/routes/requests/financial_verification.py @@ -3,15 +3,25 @@ from flask import request as http_request from . import requests_bp 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/", methods=["GET"]) def financial_verification(request_id=None): request = Requests.get(request_id) - form = FinancialForm(data=request.body.get("financial_verification")) + form = financial_form(request.body.get("financial_verification")) 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): post_data = http_request.form 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(): request_data = {"financial_verification": form.data} valid = form.perform_extra_validation( existing_request.body.get("financial_verification") ) - Requests.update(request_id, request_data) + updated_request = Requests.update(request_id, request_data) 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: form.reset() return render_template( "requests/financial_verification.html", **rerender_args ) + else: form.reset() return render_template("requests/financial_verification.html", **rerender_args) diff --git a/atst/routes/workspaces.py b/atst/routes/workspaces.py index 13ba6115..6b25a04d 100644 --- a/atst/routes/workspaces.py +++ b/atst/routes/workspaces.py @@ -1,45 +1,76 @@ -from flask import Blueprint, render_template - -from atst.domain.workspaces import Projects, Members +from flask import ( + Blueprint, + 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__) -mock_workspaces = [ - { - "name": "Unclassified IaaS and PaaS for Defense Digital Service (DDS)", - "id": "5966187a-eff9-44c3-aa15-4de7a65ac7ff", - "task_order": {"number": 123456}, - "user_count": 23, - } -] + +@bp.context_processor +def workspace(): + workspace = None + if "workspace_id" in http_request.view_args: + workspace = Workspaces.get( + g.current_user, http_request.view_args["workspace_id"] + ) + return {"workspace": workspace} @bp.route("/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//projects") def workspace_projects(workspace_id): - projects_repo = Projects() - projects = projects_repo.get_many(workspace_id) - return render_template( - "workspace_projects.html", workspace_id=workspace_id, projects=projects - ) + workspace = Workspaces.get(g.current_user, workspace_id) + return render_template("workspace_projects.html", workspace=workspace) + + +@bp.route("/workspaces/") +def show_workspace(workspace_id): + return redirect(url_for("workspaces.workspace_projects", workspace_id=workspace_id)) @bp.route("/workspaces//members") def workspace_members(workspace_id): - members_repo = Members() - members = members_repo.get_many(workspace_id) - return render_template( - "workspace_members.html", workspace_id=workspace_id, members=members - ) + workspace = Workspaces.get(g.current_user, workspace_id) + return render_template("workspace_members.html", workspace=workspace) @bp.route("/workspaces//reports") def workspace_reports(workspace_id): - return render_template( - "workspace_reports.html", workspace_id=workspace_id - ) + return render_template("workspace_reports.html", workspace_id=workspace_id) + + +@bp.route("/workspaces//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//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) + ) diff --git a/deploy/kubernetes/atst.yml b/deploy/kubernetes/atst.yml index d06b7a1f..7ee669d8 100644 --- a/deploy/kubernetes/atst.yml +++ b/deploy/kubernetes/atst.yml @@ -12,7 +12,7 @@ metadata: name: atst namespace: atat spec: - replicas: 1 + replicas: 2 strategy: type: RollingUpdate template: @@ -24,10 +24,10 @@ spec: fsGroup: 101 containers: - name: atst - image: registry.atat.codes:443/atst-prod:23e5c04 + image: registry.atat.codes:443/atst-prod:e38bc2f resources: requests: - memory: "6000Mi" + memory: "2500Mi" envFrom: - configMapRef: name: atst-envvars diff --git a/js/components/forms/new_project.js b/js/components/forms/new_project.js new file mode 100644 index 00000000..cbea6161 --- /dev/null +++ b/js/components/forms/new_project.js @@ -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) + } + } + } +} diff --git a/js/index.js b/js/index.js index 64184bc1..fa8d072a 100644 --- a/js/index.js +++ b/js/index.js @@ -9,6 +9,7 @@ import DetailsOfUse from './components/forms/details_of_use' import poc from './components/forms/poc' import financial from './components/forms/financial' import toggler from './components/toggler' +import NewProject from './components/forms/new_project' Vue.use(VTooltip) @@ -22,6 +23,7 @@ const app = new Vue({ DetailsOfUse, poc, financial, + NewProject }, methods: { closeModal: function(name) { diff --git a/pytest.ini b/pytest.ini index 3b40ee44..26ee1897 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,4 @@ [pytest] norecursedirs = .venv .git node_modules +env = + D:FLASK_ENV=test diff --git a/script/seed.py b/script/seed.py index d865b9d7..68df7b04 100644 --- a/script/seed.py +++ b/script/seed.py @@ -8,6 +8,8 @@ sys.path.append(parent_dir) from atst.app import make_config, make_app from atst.domain.users import Users 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 tests.factories import RequestFactory from atst.routes.dev import _DEV_USERS as DEV_USERS @@ -23,11 +25,20 @@ def seed_db(): pass for user in users: + requests = [] for dollar_value in [1, 200, 3000, 40000, 500000, 1000000]: request = Requests.create( user, RequestFactory.build_request_body(user, dollar_value) ) 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__": diff --git a/styles/elements/_block_lists.scss b/styles/elements/_block_lists.scss index 6cb24d11..ade36741 100644 --- a/styles/elements/_block_lists.scss +++ b/styles/elements/_block_lists.scss @@ -15,11 +15,16 @@ display: flex; flex-direction: row; justify-content: space-between; + + .icon-tooltip { + padding: 0.25rem 0.5rem; + } } @mixin block-list__title { @include h4; margin: 0; + line-height: 3rem; } @mixin block-list__footer { diff --git a/styles/elements/_icon_link.scss b/styles/elements/_icon_link.scss index 00daae3f..d9db1bd3 100644 --- a/styles/elements/_icon_link.scss +++ b/styles/elements/_icon_link.scss @@ -22,6 +22,7 @@ background: none; transition: background-color $hover-transition-time; border-radius: $gap / 2; + cursor: pointer; .icon { @include icon-color($color-primary); diff --git a/styles/elements/_icons.scss b/styles/elements/_icons.scss index 34b35a8c..b0f435fe 100644 --- a/styles/elements/_icons.scss +++ b/styles/elements/_icons.scss @@ -49,4 +49,8 @@ &.icon--large { @include icon-size(24); } + + &.icon--remove { + @include icon-color($color-red); + } } diff --git a/styles/elements/_inputs.scss b/styles/elements/_inputs.scss index 14b508ab..53fb9d9f 100644 --- a/styles/elements/_inputs.scss +++ b/styles/elements/_inputs.scss @@ -58,7 +58,7 @@ } .usa-input { - margin: ($gap * 4) ($gap * 2) ($gap * 4) 0; + margin: ($gap * 4) ($gap * 2) ($gap * 4) 0; @include media($medium-screen) { margin: ($gap * 4) 0; diff --git a/styles/elements/_panels.scss b/styles/elements/_panels.scss index 27fdc144..c6b9a510 100644 --- a/styles/elements/_panels.scss +++ b/styles/elements/_panels.scss @@ -63,13 +63,23 @@ .panel__heading { margin: $gap * 2; - @include media($medium-screen) { margin: $gap * 4; } h1, h2, h3, h4, h5, h6 { margin: 0; + display: inline-block; + } + + .icon-tooltip { + margin-left: $gap*2; + } + + &--grow { + display: flex; + flex-direction: row; + justify-content: space-between; } } } diff --git a/styles/sections/_projects_list.scss b/styles/sections/_projects_list.scss index fe62b7a6..e48bb7af 100644 --- a/styles/sections/_projects_list.scss +++ b/styles/sections/_projects_list.scss @@ -4,6 +4,10 @@ flex-direction: row; justify-content: space-between; + .usa-input { + margin: 0; + } + .project-list-item__environment__link { @include icon-link; @include icon-link-large; diff --git a/templates/base.html b/templates/base.html index 3f5125ce..bbb49fe5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,7 +1,3 @@ -{# TODO: set this context elsewhere #} -{# set context='workspace' #} -{% set context=g.navigationContext %} - diff --git a/templates/components/tooltip.html b/templates/components/tooltip.html index d1af4f0c..48a09e09 100644 --- a/templates/components/tooltip.html +++ b/templates/components/tooltip.html @@ -2,8 +2,8 @@ {% macro Tooltip(message,title='Help') -%} - + {%- endmacro %} diff --git a/templates/error_base.html b/templates/error_base.html index a9d8c0de..5ef0878d 100644 --- a/templates/error_base.html +++ b/templates/error_base.html @@ -1,7 +1,3 @@ -{# TODO: set this context elsewhere #} -{# set context='workspace' #} -{% set context=g.navigationContext %} - diff --git a/templates/navigation/global_navigation.html b/templates/navigation/global_navigation.html index b2311665..4b259e2b 100644 --- a/templates/navigation/global_navigation.html +++ b/templates/navigation/global_navigation.html @@ -1,6 +1,6 @@ {% from "components/sidenav_item.html" import SidenavItem %} -