diff --git a/Pipfile.lock b/Pipfile.lock index 2dfe651e..8a19a1a2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -271,7 +271,7 @@ "sha256:1d936da41ee06216d89fdc7ead1ee9a5da2811a8787515a976b646e110c3f622", "sha256:e4ef42e82b0b493c5849eed98b5ab49d6767caf982127e9a33167f1153b36cc5" ], - "markers": "python_version != '3.2.*' and python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.1.*' and python_version >= '2.7'", + "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": { @@ -317,7 +317,7 @@ "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" ], - "markers": "python_version < '4' and python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.2.*' and python_version >= '2.6' and python_version != '3.1.*'", + "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", @@ -449,6 +441,7 @@ "sha256:ea7cfd3aeb1544732d08bd9cfba40c5b78e3a91e17b1a0698ab81bfc5554c628", "sha256:f6d67f04abfb2b4bea7afc7fa6c18cf4c523a67956e455668be9ae42bccc21ad" ], + "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": { @@ -501,7 +494,7 @@ "sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8", "sha256:ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497" ], - "markers": "python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.3.*' and python_version != '3.0.*'", + "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": { @@ -619,7 +612,7 @@ "sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1", "sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1" ], - "markers": "python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.3.*' and python_version != '3.0.*'", + "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": { @@ -642,7 +635,7 @@ "sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7", "sha256:e31fb2767eb657cbde86c454f02e99cb846d3cd9d61b318525140214fdc0e98e" ], - "markers": "python_version >= '2.7' and python_version != '3.2.*' and python_version != '3.1.*' and python_version != '3.3.*' and python_version != '3.0.*'", + "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": { @@ -754,43 +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" - ], - "markers": "python_version < '3.7' and implementation_name == 'cpython'", - "version": "==1.1.0" - }, - "typing": { - "hashes": [ - "sha256:3a887b021a77b292e151afb75323dea88a7bc1b3dfa92176cff8e44c8b68bddf", - "sha256:b2c689d54e1144bbcfd191b0832980a21c2dbcf7b5ff7a66248a60c90e951eb8", - "sha256:d400a9344254803a2368533e4533a4200d21eb7b6b729c173bc38201a74db3f2" - ], - "version": "==3.6.4" - }, "watchdog": { "hashes": [ "sha256:7e65882adb7746039b6f3876ee174952f8eaaa34491ba34333ddf1fe35de4162" 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/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..4836fd96 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 @@ -114,6 +115,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/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 64d7ce50..dab03302 100644 --- a/atst/domain/workspaces.py +++ b/atst/domain/workspaces.py @@ -1,112 +1,71 @@ +from sqlalchemy.orm.exc import NoResultFound + +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 Workspaces(object): - 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, - } - ] + @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") + + db.session.add(workspace) + db.session.commit() + + return workspace @classmethod - def get(cls, workspace_id): - return cls.MOCK_WORKSPACES[0] + def get(cls, user, workspace_id): + try: + workspace = db.session.query(Workspace).filter_by(id=workspace_id).one() + except NoResultFound: + raise NotFoundError("workspace") + + if not Authorization.is_in_workspace(user, workspace): + raise UnauthorizedError(user, "get workspace") + + return workspace @classmethod - def get_many(cls): - return cls.MOCK_WORKSPACES - -class Projects(object): + 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 create(cls, creator_id, body): - pass + 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(cls, project_id): - pass + def get_many(cls, user): + workspaces = ( + db.session.query(Workspace) + .join(WorkspaceRole) + .filter(WorkspaceRole.user == user) + .all() + ) + return workspaces @classmethod - def get_many(cls, 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", - }, - ], - }, - ] - - @classmethod - def update(cls, request_id, request_delta): - pass - - -class Members(object): - - @classmethod - def create(cls, creator_id, body): - pass - - @classmethod - def get(cls, request_id): - pass - - @classmethod - def get_many(cls, 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 update(cls, request_id, request_delta): - pass + 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/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/requests/financial_verification.py b/atst/routes/requests/financial_verification.py index b7fc4993..c16c6e10 100644 --- a/atst/routes/requests/financial_verification.py +++ b/atst/routes/requests/financial_verification.py @@ -31,17 +31,19 @@ def update_financial_verification(request_id): existing_request = Requests.get(request_id) form = financial_form(post_data) - rerender_args = dict(request_id=request_id, f=form, extended=http_request.args.get("extended")) + 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( diff --git a/atst/routes/workspaces.py b/atst/routes/workspaces.py index 40be4aee..6b25a04d 100644 --- a/atst/routes/workspaces.py +++ b/atst/routes/workspaces.py @@ -1,35 +1,76 @@ -from flask import Blueprint, render_template, request as http_request - -from atst.domain.workspaces import Members, Projects, Workspaces +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__) + @bp.context_processor def workspace(): workspace = None if "workspace_id" in http_request.view_args: - workspace = Workspaces.get(http_request.view_args["workspace_id"]) - return { "workspace": workspace } + 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=Workspaces.get_many()) + 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 = Projects.get_many(workspace_id) - return render_template("workspace_projects.html", 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 = Members.get_many(workspace_id) - return render_template("workspace_members.html", 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") + 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/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 4ecd6aa7..3881a313 100644 --- a/js/index.js +++ b/js/index.js @@ -8,6 +8,7 @@ import checkboxinput from './components/checkbox_input' import DetailsOfUse from './components/forms/details_of_use' import poc from './components/forms/poc' import financial from './components/forms/financial' +import NewProject from './components/forms/new_project' Vue.use(VTooltip) @@ -21,6 +22,7 @@ const app = new Vue({ DetailsOfUse, poc, financial, + NewProject }, methods: { closeModal: function(name) { 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/navigation/workspace_navigation.html b/templates/navigation/workspace_navigation.html index 3ed02ea9..6dfa4f09 100644 --- a/templates/navigation/workspace_navigation.html +++ b/templates/navigation/workspace_navigation.html @@ -9,8 +9,8 @@ subnav=[ { "label": "Add New Project", - "href":"/", - "active": g.matchesPath('workspaces/projects/new'), + "href": url_for('workspaces.new_project', workspace_id=workspace.id), + "active": g.matchesPath('\/workspaces\/[A-Za-z0-9-]*\/projects'), "icon": "plus" } ] diff --git a/templates/project_edit.html.to b/templates/project_edit.html.to index 93768977..58c18bed 100644 --- a/templates/project_edit.html.to +++ b/templates/project_edit.html.to @@ -88,7 +88,7 @@ diff --git a/templates/workspace_members.html b/templates/workspace_members.html index 19cb340f..099e53a0 100644 --- a/templates/workspace_members.html +++ b/templates/workspace_members.html @@ -4,7 +4,7 @@ {% block workspace_content %} -{% if not members %} +{% if not workspace.members %} {{ EmptyState( 'There are currently no members in this Workspace.', @@ -58,7 +58,7 @@ - {% for m in members %} + {% for m in workspace.members %} {{ m['first_name'] }} {{ m['last_name'] }} {% if m['num_projects'] == '0' %} No Project Access {% endif %} diff --git a/templates/workspace_project_new.html b/templates/workspace_project_new.html new file mode 100644 index 00000000..85cdf673 --- /dev/null +++ b/templates/workspace_project_new.html @@ -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 %} + +
+ {{ form.csrf_token }} +
+
+

Add a new project

+ {{ Tooltip( + "AT-AT allows you to organize your workspace into multiple projects, each of which may have environments.", + title="learn more" + )}} +
+ + +
+ {{ TextInput(form.name) }} + {{ TextInput(form.description, paragraph=True) }} +
+
+ +
+
+

Project Environments

+ {{ 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" + )}} +
+ +
    +
  • + {{ TextInput(form.environment_name) }} + {{ Icon('x') }} Remove + +
  • +
+ + +
+ + +
+ + Cancel +
+ + + +
+
+{% endblock %} diff --git a/templates/workspace_projects.html b/templates/workspace_projects.html index ccc99759..cd2a1308 100644 --- a/templates/workspace_projects.html +++ b/templates/workspace_projects.html @@ -1,24 +1,34 @@ {% from "components/icon.html" import Icon %} +{% from "components/alert.html" import Alert %} {% extends "base_workspace.html" %} {% block workspace_content %} -{% for project in projects %} +{% if request.args.get("newWorkspace") %} + {{ Alert('Workspace created!', + message="\ +

You are now ready to create projects and environments within the JEDI Cloud.

+ ", + level='success' + ) }} +{% endif %} + +{% for project in workspace.projects %}
-

{{ project['name'] }} ({{ project['environments']|length }} environments)

- +

{{ project.name }} ({{ project.environments|length }} environments)

+
{{ Icon('edit') }} edit
    - {% for environment in project['environments'] %} + {% for environment in project.environments %}
  • {{ Icon('link') }} - {{ environment["name"]}} + {{ environment.name }}
    diff --git a/templates/workspaces.html b/templates/workspaces.html index 5f407766..14d4600c 100644 --- a/templates/workspaces.html +++ b/templates/workspaces.html @@ -11,16 +11,16 @@ - {% for w in workspaces %} + {% for workspace in workspaces %} - {{ w['name'] }}
    + {{ workspace.name }}
    - #{{ w['task_order']['number'] }} + #{{ workspace.task_order.number }} - {{ w['user_count'] }}Users + {{ workspace.user_count }}Users {% endfor %} diff --git a/templates/workspaces_blank.html.to b/templates/workspaces_blank.html.to deleted file mode 100644 index 0e5f4a0a..00000000 --- a/templates/workspaces_blank.html.to +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "base.html.to" %} - -{% block content %} - -
    -

    There are currently no JEDI workspaces

    - New Workspace -
    - - -{% end %} - diff --git a/tests/domain/test_workspace_users.py b/tests/domain/test_workspace_users.py index 2c651235..be24796b 100644 --- a/tests/domain/test_workspace_users.py +++ b/tests/domain/test_workspace_users.py @@ -1,32 +1,30 @@ -from uuid import uuid4 - from atst.domain.workspace_users import WorkspaceUsers from atst.domain.users import Users +from tests.factories import WorkspaceFactory def test_can_create_new_workspace_user(): - workspace_id = uuid4() - user = Users.create("developer") + workspace = WorkspaceFactory.create() + 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 == user.id + assert workspace_users[0].user.id == new_user.id assert workspace_users[0].user.atat_role.name == "developer" assert workspace_users[0].workspace_role.role.name == "owner" def test_can_update_existing_workspace_user(): - workspace_id = uuid4() - user = Users.create("developer") + workspace = WorkspaceFactory.create() + new_user = Users.create("developer") 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_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" diff --git a/tests/domain/test_workspaces.py b/tests/domain/test_workspaces.py new file mode 100644 index 00000000..468536a8 --- /dev/null +++ b/tests/domain/test_workspaces.py @@ -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) diff --git a/tests/factories.py b/tests/factories.py index a6370060..51be428b 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -10,6 +10,7 @@ from atst.models.pe_number import PENumber from atst.models.task_order import TaskOrder from atst.models.user import User from atst.models.role import Role +from atst.models.workspace import Workspace from atst.models.request_status_event import RequestStatusEvent from atst.domain.roles import Roles @@ -102,3 +103,12 @@ class PENumberFactory(factory.alchemy.SQLAlchemyModelFactory): class TaskOrderFactory(factory.alchemy.SQLAlchemyModelFactory): class Meta: 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) diff --git a/tests/routes/test_financial_verification.py b/tests/routes/test_financial_verification.py index 675b15e3..d6552846 100644 --- a/tests/routes/test_financial_verification.py +++ b/tests/routes/test_financial_verification.py @@ -1,12 +1,10 @@ -import re -import pytest import urllib from flask import url_for from atst.eda_client import MockEDAClient from tests.mocks import MOCK_REQUEST, MOCK_USER -from tests.factories import PENumberFactory +from tests.factories import PENumberFactory, RequestFactory class TestPENumberInForm: @@ -39,14 +37,13 @@ class TestPENumberInForm: def _set_monkeypatches(self, monkeypatch): 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) def submit_data(self, client, data, extended=False): - url_kwargs = {"request_id": MOCK_REQUEST.id} + request = RequestFactory.create(body=MOCK_REQUEST.body) + url_kwargs = {"request_id": request.id} if extended: url_kwargs["extended"] = True - response = client.post( url_for("requests.financial_verification", **url_kwargs), headers={"Content-Type": "application/x-www-form-urlencoded"}, @@ -72,7 +69,7 @@ class TestPENumberInForm: response = self.submit_data(client, data) 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): self._set_monkeypatches(monkeypatch) @@ -84,7 +81,7 @@ class TestPENumberInForm: response = self.submit_data(client, data) 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): self._set_monkeypatches(monkeypatch) @@ -132,4 +129,4 @@ class TestPENumberInForm: response = self.submit_data(client, data, extended=True) assert response.status_code == 302 - assert "/requests/financial_verification_submitted" in response.headers.get("Location") + assert "/workspaces" in response.headers.get("Location")