From 6d92755a7ff45752a8fd7acc1d6031f05436bb3b Mon Sep 17 00:00:00 2001 From: dandds Date: Thu, 13 Dec 2018 16:05:44 -0500 Subject: [PATCH 01/13] new workspace and task order routes --- .../a4cb6444eb4a_new_task_order_table.py | 46 +++++++++++++++++++ ...ac86_workspace_request_relationship_is_.py | 32 +++++++++++++ atst/app.py | 2 + atst/domain/requests/requests.py | 2 +- atst/domain/task_orders.py | 35 ++++++++++++++ atst/domain/workspaces/workspaces.py | 11 ++++- atst/forms/task_order.py | 12 +++++ atst/models/__init__.py | 1 + atst/models/task_order.py | 45 ++++++++++++++++++ atst/models/workspace.py | 4 +- atst/routes/task_orders/__init__.py | 26 +++++++++++ atst/routes/workspaces/__init__.py | 3 +- atst/routes/workspaces/new.py | 23 ++++++++++ templates/navigation/global_navigation.html | 11 +++-- templates/task_orders/edit.html | 34 ++++++++++++++ templates/workspaces/new.html | 36 +++++++++++++++ tests/domain/test_projects.py | 2 +- tests/domain/test_workspaces.py | 22 +++++---- tests/factories.py | 13 ++++++ tests/models/test_environments.py | 2 +- tests/models/test_workspace_role.py | 18 ++++---- .../task_orders/test_edit_task_order.py | 41 +++++++++++++++++ tests/routes/test_home.py | 9 ++-- tests/routes/workspaces/test_new_workspace.py | 27 +++++++++++ 24 files changed, 427 insertions(+), 30 deletions(-) create mode 100644 alembic/versions/a4cb6444eb4a_new_task_order_table.py create mode 100644 alembic/versions/c457386dac86_workspace_request_relationship_is_.py create mode 100644 atst/domain/task_orders.py create mode 100644 atst/forms/task_order.py create mode 100644 atst/models/task_order.py create mode 100644 atst/routes/task_orders/__init__.py create mode 100644 atst/routes/workspaces/new.py create mode 100644 templates/task_orders/edit.html create mode 100644 templates/workspaces/new.html create mode 100644 tests/routes/task_orders/test_edit_task_order.py create mode 100644 tests/routes/workspaces/test_new_workspace.py diff --git a/alembic/versions/a4cb6444eb4a_new_task_order_table.py b/alembic/versions/a4cb6444eb4a_new_task_order_table.py new file mode 100644 index 00000000..ce0583bd --- /dev/null +++ b/alembic/versions/a4cb6444eb4a_new_task_order_table.py @@ -0,0 +1,46 @@ +"""new task order table + +Revision ID: a4cb6444eb4a +Revises: c457386dac86 +Create Date: 2018-12-13 09:17:25.406453 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a4cb6444eb4a' +down_revision = 'c457386dac86' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('task_orders', + 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('number', sa.String(), nullable=True), + sa.Column('clin_0001', sa.Integer(), nullable=True), + sa.Column('clin_0003', sa.Integer(), nullable=True), + sa.Column('clin_1001', sa.Integer(), nullable=True), + sa.Column('clin_1003', sa.Integer(), nullable=True), + sa.Column('clin_2001', sa.Integer(), nullable=True), + sa.Column('clin_2003', sa.Integer(), nullable=True), + sa.Column('expiration_date', sa.Date(), nullable=True), + sa.Column('workspace_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('number') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('task_orders') + # ### end Alembic commands ### diff --git a/alembic/versions/c457386dac86_workspace_request_relationship_is_.py b/alembic/versions/c457386dac86_workspace_request_relationship_is_.py new file mode 100644 index 00000000..3c8f9fc7 --- /dev/null +++ b/alembic/versions/c457386dac86_workspace_request_relationship_is_.py @@ -0,0 +1,32 @@ +"""workspace request relationship is nullable + +Revision ID: c457386dac86 +Revises: 1c1394e496a7 +Create Date: 2018-12-13 08:57:09.319288 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c457386dac86' +down_revision = '1c1394e496a7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('workspaces', 'request_id', + existing_type=postgresql.UUID(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('workspaces', 'request_id', + existing_type=postgresql.UUID(), + nullable=False) + # ### end Alembic commands ### diff --git a/atst/app.py b/atst/app.py index 84348e68..0b592db8 100644 --- a/atst/app.py +++ b/atst/app.py @@ -14,6 +14,7 @@ from atst.filters import register_filters from atst.routes import bp from atst.routes.workspaces import workspaces_bp as workspace_routes from atst.routes.requests import requests_bp +from atst.routes.task_orders import task_orders_bp from atst.routes.dev import bp as dev_routes from atst.routes.users import bp as user_routes from atst.routes.errors import make_error_pages @@ -64,6 +65,7 @@ def make_app(config): app.register_blueprint(bp) app.register_blueprint(workspace_routes) app.register_blueprint(requests_bp) + app.register_blueprint(task_orders_bp) app.register_blueprint(user_routes) if ENV != "prod": app.register_blueprint(dev_routes) diff --git a/atst/domain/requests/requests.py b/atst/domain/requests/requests.py index e4d2688f..6fc1e62d 100644 --- a/atst/domain/requests/requests.py +++ b/atst/domain/requests/requests.py @@ -101,7 +101,7 @@ class Requests(object): @classmethod def approve_and_create_workspace(cls, request): approved_request = Requests.set_status(request, RequestStatus.APPROVED) - workspace = Workspaces.create(approved_request) + workspace = Workspaces.create_from_request(approved_request) RequestsQuery.add_and_commit(approved_request) diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py new file mode 100644 index 00000000..1f17e93a --- /dev/null +++ b/atst/domain/task_orders.py @@ -0,0 +1,35 @@ +from sqlalchemy.orm.exc import NoResultFound + +from atst.database import db +from atst.models.task_order import TaskOrder +from .exceptions import NotFoundError + + +class TaskOrders(object): + @classmethod + def get(cls, task_order_id): + try: + task_order = db.session.query(TaskOrder).filter_by(id=task_order_id).one() + + return task_order + except NoResultFound: + raise NotFoundError("task_order") + + @classmethod + def create(cls, workspace, creator): + task_order = TaskOrder(workspace=workspace, creator=creator) + + db.session.add(task_order) + db.session.commit() + + return task_order + + @classmethod + def update(cls, task_order, **kwargs): + for key, value in kwargs.items(): + setattr(task_order, key, value) + + db.session.add(task_order) + db.session.commit() + + return task_order diff --git a/atst/domain/workspaces/workspaces.py b/atst/domain/workspaces/workspaces.py index d8960a3d..a42602cd 100644 --- a/atst/domain/workspaces/workspaces.py +++ b/atst/domain/workspaces/workspaces.py @@ -16,7 +16,16 @@ class WorkspaceError(Exception): class Workspaces(object): @classmethod - def create(cls, request, name=None): + def create(cls, user, name): + workspace = WorkspacesQuery.create(name=name) + Workspaces._create_workspace_role( + user, workspace, "owner", status=WorkspaceRoleStatus.ACTIVE + ) + WorkspacesQuery.add_and_commit(workspace) + return workspace + + @classmethod + def create_from_request(cls, request, name=None): name = name or request.displayname workspace = WorkspacesQuery.create(request=request, name=name) Workspaces._create_workspace_role( diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py new file mode 100644 index 00000000..e7b54a28 --- /dev/null +++ b/atst/forms/task_order.py @@ -0,0 +1,12 @@ +from wtforms.fields import StringField + +from .forms import CacheableForm + + +class TaskOrderForm(CacheableForm): + clin_0001 = StringField("CLIN 0001") + clin_0003 = StringField("CLIN 0003") + clin_1001 = StringField("CLIN 1001") + clin_1003 = StringField("CLIN 1003") + clin_2001 = StringField("CLIN 2001") + clin_2003 = StringField("CLIN 2003") diff --git a/atst/models/__init__.py b/atst/models/__init__.py index 972e2064..0116c5ff 100644 --- a/atst/models/__init__.py +++ b/atst/models/__init__.py @@ -19,3 +19,4 @@ from .request_review import RequestReview from .request_internal_comment import RequestInternalComment from .audit_event import AuditEvent from .invitation import Invitation +from .task_order import TaskOrder diff --git a/atst/models/task_order.py b/atst/models/task_order.py new file mode 100644 index 00000000..7d2a9235 --- /dev/null +++ b/atst/models/task_order.py @@ -0,0 +1,45 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, Date +from sqlalchemy.orm import relationship + +from atst.models import Base, types, mixins + + +class TaskOrder(Base, mixins.TimestampsMixin): + __tablename__ = "task_orders" + + id = types.Id() + number = Column(String, unique=True) + clin_0001 = Column(Integer) + clin_0003 = Column(Integer) + clin_1001 = Column(Integer) + clin_1003 = Column(Integer) + clin_2001 = Column(Integer) + clin_2003 = Column(Integer) + expiration_date = Column(Date) + + workspace_id = Column(ForeignKey("workspaces.id")) + workspace = relationship("Workspace") + + user_id = Column(ForeignKey("users.id")) + creator = relationship("User") + + @property + def budget(self): + return sum( + filter( + None, + [ + self.clin_0001, + self.clin_0003, + self.clin_1001, + self.clin_1003, + self.clin_2001, + self.clin_2003, + ], + ) + ) + + def __repr__(self): + return "".format( + self.number, self.budget, self.expiration_date, self.id + ) diff --git a/atst/models/workspace.py b/atst/models/workspace.py index be7c1ccf..5014c4ee 100644 --- a/atst/models/workspace.py +++ b/atst/models/workspace.py @@ -13,10 +13,12 @@ class Workspace(Base, mixins.TimestampsMixin, mixins.AuditableMixin): id = types.Id() name = Column(String) - request_id = Column(ForeignKey("requests.id"), nullable=False) + request_id = Column(ForeignKey("requests.id"), nullable=True) projects = relationship("Project", back_populates="workspace") roles = relationship("WorkspaceRole") + task_orders = relationship("TaskOrder") + @property def owner(self): def _is_workspace_owner(workspace_role): diff --git a/atst/routes/task_orders/__init__.py b/atst/routes/task_orders/__init__.py new file mode 100644 index 00000000..f20a6f1d --- /dev/null +++ b/atst/routes/task_orders/__init__.py @@ -0,0 +1,26 @@ +from flask import Blueprint, request as http_request, render_template + +from atst.domain.task_orders import TaskOrders +from atst.forms.task_order import TaskOrderForm + +task_orders_bp = Blueprint("task_orders", __name__) + + +@task_orders_bp.route("/task_order/edit/") +def edit(task_order_id): + form = TaskOrderForm() + task_order = TaskOrders.get(task_order_id) + return render_template("task_orders/edit.html", form=form, task_order=task_order) + + +@task_orders_bp.route("/task_order/edit/", methods=["POST"]) +def update(task_order_id): + form = TaskOrderForm(http_request.form) + task_order = TaskOrders.get(task_order_id) + if form.validate(): + TaskOrders.update(task_order, **form.data) + return "i did it" + else: + return render_template( + "task_orders/edit.html", form=form, task_order=task_order + ) diff --git a/atst/routes/workspaces/__init__.py b/atst/routes/workspaces/__init__.py index 16e1f746..eb416c6b 100644 --- a/atst/routes/workspaces/__init__.py +++ b/atst/routes/workspaces/__init__.py @@ -1,4 +1,4 @@ -from flask import Blueprint, request as http_request, g +from flask import Blueprint, request as http_request, g, render_template workspaces_bp = Blueprint("workspaces", __name__) @@ -6,6 +6,7 @@ from . import index from . import projects from . import members from . import invitations +from . import new from atst.domain.exceptions import UnauthorizedError from atst.domain.workspaces import Workspaces from atst.domain.authz import Authorization diff --git a/atst/routes/workspaces/new.py b/atst/routes/workspaces/new.py new file mode 100644 index 00000000..55895ab4 --- /dev/null +++ b/atst/routes/workspaces/new.py @@ -0,0 +1,23 @@ +from flask import g, redirect, url_for, render_template, request as http_request + +from . import workspaces_bp +from atst.domain.task_orders import TaskOrders +from atst.domain.workspaces import Workspaces +from atst.forms.workspace import WorkspaceForm + + +@workspaces_bp.route("/workspaces/new") +def new(): + form = WorkspaceForm() + return render_template("workspaces/new.html", form=form) + + +@workspaces_bp.route("/workspaces/new", methods=["POST"]) +def create(): + form = WorkspaceForm(http_request.form) + if form.validate(): + ws = Workspaces.create(g.current_user, form.name.data) + task_order = TaskOrders.create(workspace=ws, creator=g.current_user) + return redirect(url_for("task_orders.edit", task_order_id=task_order.id)) + else: + return render_template("workspaces/new.html", form=form) diff --git a/templates/navigation/global_navigation.html b/templates/navigation/global_navigation.html index 093b86bd..89952e96 100644 --- a/templates/navigation/global_navigation.html +++ b/templates/navigation/global_navigation.html @@ -11,9 +11,14 @@ ] ) }} - {% if g.current_user.has_workspaces %} - {{ SidenavItem("Workspaces", href="/workspaces", icon="cloud", active=g.matchesPath('/workspaces')) }} - {% endif %} + {{ SidenavItem("Workspaces", + href="/workspaces", + icon="cloud", + active=g.matchesPath('/workspaces'), + subnav=[ + {"label":"New Workspace", "href":url_for("workspaces.new"), "icon": "plus", "active": g.matchesPath('/workspaces/new')}, + ] + ) }} {% if g.Authorization.has_atat_permission(g.current_user, g.Permissions.VIEW_AUDIT_LOG) %} {{ SidenavItem("Activity History", url_for('atst.activity_history'), icon="time", active=g.matchesPath('/activity-history')) }} diff --git a/templates/task_orders/edit.html b/templates/task_orders/edit.html new file mode 100644 index 00000000..63362b63 --- /dev/null +++ b/templates/task_orders/edit.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% from "components/text_input.html" import TextInput %} + +{% block content %} + +{% include "fragments/flash.html" %} + +
+ {{ form.csrf_token }} + +
+ +
+

Task Order

+
+ +
+ {{ TextInput(form.clin_0001) }} + {{ TextInput(form.clin_0003) }} + {{ TextInput(form.clin_1001) }} + {{ TextInput(form.clin_1003) }} + {{ TextInput(form.clin_2001) }} + {{ TextInput(form.clin_2003) }} +
+
+ +
+ +
+ +
+ +{% endblock %} diff --git a/templates/workspaces/new.html b/templates/workspaces/new.html new file mode 100644 index 00000000..eea8a657 --- /dev/null +++ b/templates/workspaces/new.html @@ -0,0 +1,36 @@ +{% extends "workspaces/base.html" %} + +{% from "components/icon.html" import Icon %} +{% from "components/text_input.html" import TextInput %} + +{% block workspace_content %} + +{% include "fragments/flash.html" %} + +
+ {{ form.csrf_token }} + +
+ +
+

Workspace Settings

+
+ +
+ {{ TextInput(form.name, validation="workspaceName") }} +
+
+ + + + + +
+ +{% endblock %} diff --git a/tests/domain/test_projects.py b/tests/domain/test_projects.py index 4fa0a7bb..97bd27e1 100644 --- a/tests/domain/test_projects.py +++ b/tests/domain/test_projects.py @@ -5,7 +5,7 @@ from atst.domain.workspaces import Workspaces def test_create_project_with_multiple_environments(): request = RequestFactory.create() - workspace = Workspaces.create(request) + workspace = Workspaces.create_from_request(request) project = Projects.create( workspace.owner, workspace, "My Test Project", "Test", ["dev", "prod"] ) diff --git a/tests/domain/test_workspaces.py b/tests/domain/test_workspaces.py index 63b408d9..4be5e078 100644 --- a/tests/domain/test_workspaces.py +++ b/tests/domain/test_workspaces.py @@ -28,12 +28,12 @@ def request_(workspace_owner): @pytest.fixture(scope="function") def workspace(request_): - workspace = Workspaces.create(request_) + workspace = Workspaces.create_from_request(request_) return workspace def test_can_create_workspace(request_): - workspace = Workspaces.create(request_, name="frugal-whale") + workspace = Workspaces.create_from_request(request_, name="frugal-whale") assert workspace.name == "frugal-whale" @@ -163,7 +163,9 @@ def test_need_permission_to_update_workspace_role_role(workspace, workspace_owne def test_owner_can_view_workspace_members(workspace, workspace_owner): workspace_owner = UserFactory.create() - workspace = Workspaces.create(RequestFactory.create(creator=workspace_owner)) + workspace = Workspaces.create_from_request( + RequestFactory.create(creator=workspace_owner) + ) workspace = Workspaces.get_with_members(workspace_owner, workspace.id) assert workspace @@ -256,7 +258,7 @@ def test_for_user_returns_active_workspaces_for_user(workspace, workspace_owner) WorkspaceRoleFactory.create( user=bob, workspace=workspace, status=WorkspaceRoleStatus.ACTIVE ) - Workspaces.create(RequestFactory.create()) + Workspaces.create_from_request(RequestFactory.create()) bobs_workspaces = Workspaces.for_user(bob) @@ -266,7 +268,7 @@ def test_for_user_returns_active_workspaces_for_user(workspace, workspace_owner) def test_for_user_does_not_return_inactive_workspaces(workspace, workspace_owner): bob = UserFactory.from_atat_role("default") Workspaces.add_member(workspace, bob, "developer") - Workspaces.create(RequestFactory.create()) + Workspaces.create_from_request(RequestFactory.create()) bobs_workspaces = Workspaces.for_user(bob) assert len(bobs_workspaces) == 0 @@ -274,7 +276,7 @@ def test_for_user_does_not_return_inactive_workspaces(workspace, workspace_owner def test_for_user_returns_all_workspaces_for_ccpo(workspace, workspace_owner): sam = UserFactory.from_atat_role("ccpo") - Workspaces.create(RequestFactory.create()) + Workspaces.create_from_request(RequestFactory.create()) sams_workspaces = Workspaces.for_user(sam) assert len(sams_workspaces) == 2 @@ -282,7 +284,9 @@ def test_for_user_returns_all_workspaces_for_ccpo(workspace, workspace_owner): def test_get_for_update_information(): workspace_owner = UserFactory.create() - workspace = Workspaces.create(RequestFactory.create(creator=workspace_owner)) + workspace = Workspaces.create_from_request( + RequestFactory.create(creator=workspace_owner) + ) owner_ws = Workspaces.get_for_update_information(workspace_owner, workspace.id) assert workspace == owner_ws @@ -300,8 +304,8 @@ def test_get_for_update_information(): def test_can_create_workspaces_with_matching_names(): workspace_name = "Great Workspace" - Workspaces.create(RequestFactory.create(), name=workspace_name) - Workspaces.create(RequestFactory.create(), name=workspace_name) + Workspaces.create_from_request(RequestFactory.create(), name=workspace_name) + Workspaces.create_from_request(RequestFactory.create(), name=workspace_name) def test_can_revoke_workspace_access(): diff --git a/tests/factories.py b/tests/factories.py index 8c7762b6..7dbf90cb 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -14,6 +14,7 @@ from atst.models.request_status_event import RequestStatusEvent, RequestStatus from atst.models.pe_number import PENumber from atst.models.project import Project from atst.models.legacy_task_order import LegacyTaskOrder, Source, FundingType +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 @@ -345,3 +346,15 @@ class InvitationFactory(Base): email = factory.Faker("email") status = InvitationStatus.PENDING expiration_time = Invitations.current_expiration_time() + + +class TaskOrderFactory(Base): + class Meta: + model = TaskOrder + + clin_0001 = random.randrange(100, 100_000) + clin_0003 = random.randrange(100, 100_000) + clin_1001 = random.randrange(100, 100_000) + clin_1003 = random.randrange(100, 100_000) + clin_2001 = random.randrange(100, 100_000) + clin_2003 = random.randrange(100, 100_000) diff --git a/tests/models/test_environments.py b/tests/models/test_environments.py index 32faeef2..ccbd7fb6 100644 --- a/tests/models/test_environments.py +++ b/tests/models/test_environments.py @@ -8,7 +8,7 @@ def test_add_user_to_environment(): owner = UserFactory.create() developer = UserFactory.from_atat_role("developer") - workspace = Workspaces.create(RequestFactory.create(creator=owner)) + workspace = Workspaces.create_from_request(RequestFactory.create(creator=owner)) project = Projects.create( owner, workspace, "my test project", "It's mine.", ["dev", "staging", "prod"] ) diff --git a/tests/models/test_workspace_role.py b/tests/models/test_workspace_role.py index 284415bf..fa704bc5 100644 --- a/tests/models/test_workspace_role.py +++ b/tests/models/test_workspace_role.py @@ -25,7 +25,7 @@ def test_has_no_ws_role_history(session): owner = UserFactory.create() user = UserFactory.create() - workspace = Workspaces.create(RequestFactory.create(creator=owner)) + workspace = Workspaces.create_from_request(RequestFactory.create(creator=owner)) workspace_role = WorkspaceRoles.add(user, workspace.id, "developer") create_event = ( session.query(AuditEvent) @@ -42,7 +42,7 @@ def test_has_ws_role_history(session): owner = UserFactory.create() user = UserFactory.create() - workspace = Workspaces.create(RequestFactory.create(creator=owner)) + workspace = Workspaces.create_from_request(RequestFactory.create(creator=owner)) role = session.query(Role).filter(Role.name == "developer").one() # in order to get the history, we don't want the WorkspaceRoleFactory # to commit after create() @@ -67,7 +67,7 @@ def test_has_ws_status_history(session): owner = UserFactory.create() user = UserFactory.create() - workspace = Workspaces.create(RequestFactory.create(creator=owner)) + workspace = Workspaces.create_from_request(RequestFactory.create(creator=owner)) # in order to get the history, we don't want the WorkspaceRoleFactory # to commit after create() WorkspaceRoleFactory._meta.sqlalchemy_session_persistence = "flush" @@ -89,7 +89,7 @@ def test_has_ws_status_history(session): def test_has_no_env_role_history(session): owner = UserFactory.create() user = UserFactory.create() - workspace = Workspaces.create(RequestFactory.create(creator=owner)) + workspace = Workspaces.create_from_request(RequestFactory.create(creator=owner)) project = ProjectFactory.create(workspace=workspace) environment = EnvironmentFactory.create(project=project, name="new environment!") @@ -108,7 +108,7 @@ def test_has_no_env_role_history(session): def test_has_env_role_history(session): owner = UserFactory.create() user = UserFactory.create() - workspace = Workspaces.create(RequestFactory.create(creator=owner)) + workspace = Workspaces.create_from_request(RequestFactory.create(creator=owner)) workspace_role = WorkspaceRoleFactory.create(workspace=workspace, user=user) project = ProjectFactory.create(workspace=workspace) environment = EnvironmentFactory.create(project=project, name="new environment!") @@ -133,7 +133,7 @@ def test_event_details(): owner = UserFactory.create() user = UserFactory.create() - workspace = Workspaces.create(RequestFactory.create(creator=owner)) + workspace = Workspaces.create_from_request(RequestFactory.create(creator=owner)) workspace_role = WorkspaceRoles.add(user, workspace.id, "developer") assert workspace_role.event_details["updated_user_name"] == user.displayname @@ -150,7 +150,7 @@ def test_has_no_environment_roles(): "workspace_role": "developer", } - workspace = Workspaces.create(RequestFactory.create(creator=owner)) + workspace = Workspaces.create_from_request(RequestFactory.create(creator=owner)) workspace_role = Workspaces.create_member(owner, workspace, developer_data) assert not workspace_role.has_environment_roles @@ -166,7 +166,7 @@ def test_has_environment_roles(): "workspace_role": "developer", } - workspace = Workspaces.create(RequestFactory.create(creator=owner)) + workspace = Workspaces.create_from_request(RequestFactory.create(creator=owner)) workspace_role = Workspaces.create_member(owner, workspace, developer_data) project = Projects.create( owner, workspace, "my test project", "It's mine.", ["dev", "staging", "prod"] @@ -185,7 +185,7 @@ def test_role_displayname(): "workspace_role": "developer", } - workspace = Workspaces.create(RequestFactory.create(creator=owner)) + workspace = Workspaces.create_from_request(RequestFactory.create(creator=owner)) workspace_role = Workspaces.create_member(owner, workspace, developer_data) assert workspace_role.role_displayname == "Developer" diff --git a/tests/routes/task_orders/test_edit_task_order.py b/tests/routes/task_orders/test_edit_task_order.py new file mode 100644 index 00000000..cd097370 --- /dev/null +++ b/tests/routes/task_orders/test_edit_task_order.py @@ -0,0 +1,41 @@ +import pytest +from flask import url_for + +from atst.database import db +from atst.models.workspace import Workspace + +from tests.factories import UserFactory, WorkspaceFactory, TaskOrderFactory + + +def test_edit_task_order(client, user_session): + creator = UserFactory.create() + task_order = TaskOrderFactory.create( + creator=creator, workspace=WorkspaceFactory.create() + ) + user_session() + response = client.get(url_for("task_orders.edit", task_order_id=task_order.id)) + assert response.status_code == 200 + + +def test_create_new_workspace(client, user_session): + creator = UserFactory.create() + task_order = TaskOrderFactory.create( + creator=creator, workspace=WorkspaceFactory.create() + ) + user_session() + + response = client.post( + url_for("task_orders.update", task_order_id=task_order.id), + data={ + "clin_0001": 12345, + "clin_0003": 12345, + "clin_1001": 12345, + "clin_1003": 12345, + "clin_2001": 12345, + "clin_2003": 12345, + }, + follow_redirects=False, + ) + + assert response.status_code == 200 + assert task_order.clin_0001 == 12345 diff --git a/tests/routes/test_home.py b/tests/routes/test_home.py index 83ac2eef..082efca6 100644 --- a/tests/routes/test_home.py +++ b/tests/routes/test_home.py @@ -1,3 +1,5 @@ +import pytest + from tests.factories import UserFactory, WorkspaceFactory, RequestFactory from atst.domain.workspaces import Workspaces @@ -9,6 +11,7 @@ def test_user_with_workspaces_has_workspaces_nav(client, user_session): assert b'href="/workspaces"' in response.data +@pytest.mark.skip(reason="this may no longer be accurate") def test_user_without_workspaces_has_no_workspaces_nav(client, user_session): user = UserFactory.create() user_session(user) @@ -26,7 +29,7 @@ def test_request_owner_with_no_workspaces_redirected_to_requests(client, user_se def test_request_owner_with_one_workspace_redirected_to_reports(client, user_session): request = RequestFactory.create() - workspace = Workspaces.create(request) + workspace = Workspaces.create_from_request(request) user_session(request.creator) response = client.get("/home", follow_redirects=False) @@ -38,8 +41,8 @@ def test_request_owner_with_more_than_one_workspace_redirected_to_workspaces( client, user_session ): request_creator = UserFactory.create() - Workspaces.create(RequestFactory.create(creator=request_creator)) - Workspaces.create(RequestFactory.create(creator=request_creator)) + Workspaces.create_from_request(RequestFactory.create(creator=request_creator)) + Workspaces.create_from_request(RequestFactory.create(creator=request_creator)) user_session(request_creator) response = client.get("/home", follow_redirects=False) diff --git a/tests/routes/workspaces/test_new_workspace.py b/tests/routes/workspaces/test_new_workspace.py new file mode 100644 index 00000000..2846ea56 --- /dev/null +++ b/tests/routes/workspaces/test_new_workspace.py @@ -0,0 +1,27 @@ +from flask import url_for + +from atst.database import db +from atst.models.workspace import Workspace + + +def get_workspace_by_name(name): + return db.session.query(Workspace).filter_by(name=name).one() + + +def test_get_new_workspace(client, user_session): + user_session() + response = client.get(url_for("workspaces.new")) + assert response.status_code == 200 + + +def test_create_new_workspace(client, user_session): + user_session() + ws_name = "mos-eisley" + response = client.post( + url_for("workspaces.create"), data={"name": ws_name}, follow_redirects=False + ) + assert response.status_code == 302 + workspace = get_workspace_by_name(ws_name) + assert workspace.name == ws_name + task_order = workspace.task_orders[0] + assert str(task_order.id) in response.headers.get("Location") From c5580733ba51a41937a779177e9f6e1d75df53ab Mon Sep 17 00:00:00 2001 From: dandds Date: Fri, 14 Dec 2018 13:19:22 -0500 Subject: [PATCH 02/13] fields for the new task order --- .../ea06f5863083_update_task_order_columns.py | 96 ++++++++++++++++++ atst/forms/data.py | 36 +++++++ atst/forms/task_order.py | 98 +++++++++++++++++-- atst/models/task_order.py | 55 +++++++---- templates/task_orders/edit.html | 40 ++++++-- tests/factories.py | 23 +++-- .../task_orders/test_edit_task_order.py | 11 +-- 7 files changed, 308 insertions(+), 51 deletions(-) create mode 100644 alembic/versions/ea06f5863083_update_task_order_columns.py diff --git a/alembic/versions/ea06f5863083_update_task_order_columns.py b/alembic/versions/ea06f5863083_update_task_order_columns.py new file mode 100644 index 00000000..03b6d744 --- /dev/null +++ b/alembic/versions/ea06f5863083_update_task_order_columns.py @@ -0,0 +1,96 @@ +"""update task order columns + +Revision ID: ea06f5863083 +Revises: a4cb6444eb4a +Create Date: 2018-12-14 13:19:11.956511 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ea06f5863083' +down_revision = 'a4cb6444eb4a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_orders', sa.Column('app_migration', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('clin_01', sa.Integer(), nullable=True)) + op.add_column('task_orders', sa.Column('clin_02', sa.Integer(), nullable=True)) + op.add_column('task_orders', sa.Column('clin_03', sa.Integer(), nullable=True)) + op.add_column('task_orders', sa.Column('clin_04', sa.Integer(), nullable=True)) + op.add_column('task_orders', sa.Column('complexity', sa.ARRAY(sa.String()), nullable=True)) + op.add_column('task_orders', sa.Column('complexity_other', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('cor_dod_id', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('cor_email', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('cor_first_name', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('cor_last_name', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('defense_component', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('dev_team', sa.ARRAY(sa.String()), nullable=True)) + op.add_column('task_orders', sa.Column('dev_team_other', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('end_date', sa.Date(), nullable=True)) + op.add_column('task_orders', sa.Column('ko_dod_id', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('ko_email', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('ko_first_name', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('ko_last_name', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('loa', sa.ARRAY(sa.String()), nullable=True)) + op.add_column('task_orders', sa.Column('native_apps', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('scope', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('so_dod_id', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('so_email', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('so_first_name', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('so_last_name', sa.String(), nullable=True)) + op.add_column('task_orders', sa.Column('start_date', sa.Date(), nullable=True)) + op.add_column('task_orders', sa.Column('team_experience', sa.String(), nullable=True)) + op.drop_column('task_orders', 'expiration_date') + op.drop_column('task_orders', 'clin_1003') + op.drop_column('task_orders', 'clin_0001') + op.drop_column('task_orders', 'clin_2003') + op.drop_column('task_orders', 'clin_1001') + op.drop_column('task_orders', 'clin_2001') + op.drop_column('task_orders', 'clin_0003') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_orders', sa.Column('clin_0003', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('task_orders', sa.Column('clin_2001', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('task_orders', sa.Column('clin_1001', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('task_orders', sa.Column('clin_2003', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('task_orders', sa.Column('clin_0001', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('task_orders', sa.Column('clin_1003', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('task_orders', sa.Column('expiration_date', sa.DATE(), autoincrement=False, nullable=True)) + op.drop_column('task_orders', 'team_experience') + op.drop_column('task_orders', 'start_date') + op.drop_column('task_orders', 'so_last_name') + op.drop_column('task_orders', 'so_first_name') + op.drop_column('task_orders', 'so_email') + op.drop_column('task_orders', 'so_dod_id') + op.drop_column('task_orders', 'scope') + op.drop_column('task_orders', 'native_apps') + op.drop_column('task_orders', 'loa') + op.drop_column('task_orders', 'ko_last_name') + op.drop_column('task_orders', 'ko_first_name') + op.drop_column('task_orders', 'ko_email') + op.drop_column('task_orders', 'ko_dod_id') + op.drop_column('task_orders', 'end_date') + op.drop_column('task_orders', 'dev_team_other') + op.drop_column('task_orders', 'dev_team') + op.drop_column('task_orders', 'defense_component') + op.drop_column('task_orders', 'cor_last_name') + op.drop_column('task_orders', 'cor_first_name') + op.drop_column('task_orders', 'cor_email') + op.drop_column('task_orders', 'cor_dod_id') + op.drop_column('task_orders', 'complexity_other') + op.drop_column('task_orders', 'complexity') + op.drop_column('task_orders', 'clin_04') + op.drop_column('task_orders', 'clin_03') + op.drop_column('task_orders', 'clin_02') + op.drop_column('task_orders', 'clin_01') + op.drop_column('task_orders', 'app_migration') + # ### end Alembic commands ### diff --git a/atst/forms/data.py b/atst/forms/data.py index d1577a86..b02cfb64 100644 --- a/atst/forms/data.py +++ b/atst/forms/data.py @@ -173,3 +173,39 @@ FUNDING_TYPES = [ ] TASK_ORDER_SOURCES = [("MANUAL", "Manual"), ("EDA", "EDA")] + +APP_MIGRATION = [ + ("on_premise", "Yes, migrating from an on-premise data center"), + ("cloud", "Yes, migrating from another cloud provider "), + ("none", "Not planning to migrate any applications "), + ("not_sure", "Not Sure"), +] + +PROJECT_COMPLEXITY = [ + ("storage", "Storage "), + ("data_analytics", "Data Analytics "), + ("conus", "CONUS Only Access "), + ("oconus", "OCONUS Access "), + ("tactical_edge", "Tactical Edge Access "), + ("not_sure", "Not Sure "), + ("other", "Other"), +] + +DEV_TEAM = [ + ("government", "Government"), + ("civilians", "Civilians"), + ("military", "Military "), + ("contractor", "Contractor "), + ("other", "Other"), +] + +TEAM_EXPERIENCE = [ + ("none", "No previous experience"), + ("planned", "Researched or planned a cloud build or migration"), + ("built_1", "Built or Migrated 1-2 applications"), + ("built_3", "Built or Migrated 3-5 applications"), + ( + "built_many", + "Built or migrated many applications, or consulted on several such projects", + ), +] diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index e7b54a28..19774824 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -1,12 +1,96 @@ -from wtforms.fields import StringField +from wtforms.fields import ( + DateField, + IntegerField, + RadioField, + SelectField, + SelectMultipleField, + StringField, + TextAreaField, +) from .forms import CacheableForm +from .data import ( + SERVICE_BRANCHES, + APP_MIGRATION, + PROJECT_COMPLEXITY, + DEV_TEAM, + TEAM_EXPERIENCE, +) class TaskOrderForm(CacheableForm): - clin_0001 = StringField("CLIN 0001") - clin_0003 = StringField("CLIN 0003") - clin_1001 = StringField("CLIN 1001") - clin_1003 = StringField("CLIN 1003") - clin_2001 = StringField("CLIN 2001") - clin_2003 = StringField("CLIN 2003") + scope = TextAreaField( + "Cloud Project Scope", + description="The name of your office or organization. You can add multiple applications to your portfolio. Your task orders are used to pay for these applications and their environments", + ) + defense_component = SelectField( + "Department of Defense Component", + description="Your team's plan for using the cloud, such as migrating an existing application or creating a prototype.", + choices=SERVICE_BRANCHES, + ) + app_migration = RadioField( + "App Migration", + description="Do you plan to migrate existing application(s) to the cloud?", + choices=APP_MIGRATION, + default="", + ) + native_apps = RadioField( + "Native Apps", + description="Do you plan to develop application(s) natively in the cloud? ", + choices=[("yes", "Yes"), ("no", "No"), ("not_sure", "Not Sure")], + ) + complexity = SelectMultipleField( + "Project Complexity", + description="Which of these describes how complex your team's use of the cloud will be? (Select all that apply.)", + choices=PROJECT_COMPLEXITY, + default="", + ) + complexity_other = StringField("?") + dev_team = SelectMultipleField( + "Development Team", + description="Which people or teams will be completing the development work for your cloud applications?", + choices=DEV_TEAM, + default="", + ) + dev_team_other = StringField("?") + team_experience = RadioField( + "Team Experience", + description="How much experience does your team have with development in the cloud?", + choices=TEAM_EXPERIENCE, + default="", + ) + start_date = DateField( + "Period of Performance", + description="Select a start and end date for your Task Order to be active. Please note, this will likely be revised once your Task Order has been approved.", + ) + end_date = DateField("Period of Performance") + clin_01 = IntegerField( + "CLIN 01 : Unclassified Cloud Offerings", + description="UNCLASSIFIED Infrastructure as a Service (IaaS) and Platform as a Service (PaaS) offerings. ", + ) + clin_02 = IntegerField( + "CLIN 02: Classified Cloud Offerings", + description="CLASSIFIED Infrastructure as a Service (IaaS) and Platform as a Service (PaaS) offerings. ", + ) + clin_03 = IntegerField( + "CLIN 03: Unclassified Cloud Support and Assistance", + description="UNCLASSIFIED technical guidance from the cloud service provider, including architecture, configuration of IaaS and PaaS, integration, troubleshooting assistance, and other services.", + ) + clin_04 = IntegerField( + "CLIN 04: Classified Cloud Support and Assistance", + description="CLASSIFIED technical guidance from the cloud service provider, including architecture, configuration of IaaS and PaaS, integration, troubleshooting assistance, and other services.", + ) + ko_first_name = StringField("First Name") + ko_last_name = StringField("Last Name") + ko_email = StringField("Email") + ko_dod_id = StringField("DOD ID") + cor_first_name = StringField("First Name") + cor_last_name = StringField("Last Name") + cor_email = StringField("Email") + cor_dod_id = StringField("DOD ID") + so_first_name = StringField("First Name") + so_last_name = StringField("Last Name") + so_email = StringField("Email") + so_dod_id = StringField("DOD ID") + number = StringField("Task Order Number") + loa = StringField("Line of Accounting (LOA)") diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 7d2a9235..b987f016 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -1,4 +1,5 @@ from sqlalchemy import Column, Integer, String, ForeignKey, Date +from sqlalchemy.types import ARRAY from sqlalchemy.orm import relationship from atst.models import Base, types, mixins @@ -8,14 +9,6 @@ class TaskOrder(Base, mixins.TimestampsMixin): __tablename__ = "task_orders" id = types.Id() - number = Column(String, unique=True) - clin_0001 = Column(Integer) - clin_0003 = Column(Integer) - clin_1001 = Column(Integer) - clin_1003 = Column(Integer) - clin_2001 = Column(Integer) - clin_2003 = Column(Integer) - expiration_date = Column(Date) workspace_id = Column(ForeignKey("workspaces.id")) workspace = relationship("Workspace") @@ -23,23 +16,43 @@ class TaskOrder(Base, mixins.TimestampsMixin): user_id = Column(ForeignKey("users.id")) creator = relationship("User") + scope = Column(String) # Cloud Project Scope + defense_component = Column(String) # Department of Defense Component + app_migration = Column(String) # App Migration + native_apps = Column(String) # Native Apps + complexity = Column(ARRAY(String)) # Project Complexity + complexity_other = Column(String) + dev_team = Column(ARRAY(String)) # Development Team + dev_team_other = Column(String) + team_experience = Column(String) # Team Experience + start_date = Column(Date) # Period of Performance + end_date = Column(Date) + clin_01 = Column(Integer) # CLIN 01 : Unclassified Cloud Offerings + clin_02 = Column(Integer) # CLIN 02: Classified Cloud Offerings + clin_03 = Column(Integer) # CLIN 03: Unclassified Cloud Support and Assistance + clin_04 = Column(Integer) # CLIN 04: Classified Cloud Support and Assistance + ko_first_name = Column(String) # First Name + ko_last_name = Column(String) # Last Name + ko_email = Column(String) # Email + ko_dod_id = Column(String) # DOD ID + cor_first_name = Column(String) # First Name + cor_last_name = Column(String) # Last Name + cor_email = Column(String) # Email + cor_dod_id = Column(String) # DOD ID + so_first_name = Column(String) # First Name + so_last_name = Column(String) # Last Name + so_email = Column(String) # Email + so_dod_id = Column(String) # DOD ID + number = Column(String, unique=True) # Task Order Number + loa = Column(ARRAY(String)) # Line of Accounting (LOA) + @property def budget(self): return sum( - filter( - None, - [ - self.clin_0001, - self.clin_0003, - self.clin_1001, - self.clin_1003, - self.clin_2001, - self.clin_2003, - ], - ) + filter(None, [self.clin_01, self.clin_02, self.clin_03, self.clin_04]) ) def __repr__(self): - return "".format( - self.number, self.budget, self.expiration_date, self.id + return "".format( + self.number, self.budget, self.end_date, self.id ) diff --git a/templates/task_orders/edit.html b/templates/task_orders/edit.html index 63362b63..95ac6f03 100644 --- a/templates/task_orders/edit.html +++ b/templates/task_orders/edit.html @@ -1,6 +1,8 @@ {% extends "base.html" %} {% from "components/text_input.html" import TextInput %} +{% from "components/options_input.html" import OptionsInput %} +{% from "components/date_input.html" import DateInput %} {% block content %} @@ -16,12 +18,38 @@
- {{ TextInput(form.clin_0001) }} - {{ TextInput(form.clin_0003) }} - {{ TextInput(form.clin_1001) }} - {{ TextInput(form.clin_1003) }} - {{ TextInput(form.clin_2001) }} - {{ TextInput(form.clin_2003) }} + {{ TextInput(form.scope, paragraph=True) }} + {{ OptionsInput(form.defense_component) }} + {{ OptionsInput(form.app_migration) }} + {{ OptionsInput(form.native_apps) }} + {{ OptionsInput(form.complexity) }} + {{ TextInput(form.complexity_other) }} + {{ OptionsInput(form.dev_team) }} + {{ TextInput(form.dev_team_other) }} + {{ OptionsInput(form.team_experience) }} + {{ DateInput(form.start_date, placeholder='MM / DD / YYYY', validation='date') }} + {{ DateInput(form.end_date, placeholder='MM / DD / YYYY', validation='date') }} + {{ TextInput(form.clin_01, validation='dollars') }} + {{ TextInput(form.clin_02, validation='dollars') }} + {{ TextInput(form.clin_03, validation='dollars') }} + {{ TextInput(form.clin_04, validation='dollars') }} +

Contracting Officer (KO) Information

+ {{ TextInput(form.ko_first_name) }} + {{ TextInput(form.ko_last_name) }} + {{ TextInput(form.ko_email) }} + {{ TextInput(form.ko_dod_id) }} +

Contractive Officer Representative (COR) Information

+ {{ TextInput(form.cor_first_name) }} + {{ TextInput(form.cor_last_name) }} + {{ TextInput(form.cor_email) }} + {{ TextInput(form.cor_dod_id) }} +

Security Officer Information

+ {{ TextInput(form.so_first_name) }} + {{ TextInput(form.so_last_name) }} + {{ TextInput(form.so_email) }} + {{ TextInput(form.so_dod_id) }} + {{ TextInput(form.number) }} + {{ TextInput(form.loa) }}
diff --git a/tests/factories.py b/tests/factories.py index 7dbf90cb..2ca7d389 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -5,7 +5,7 @@ from uuid import uuid4 import datetime from faker import Faker as _Faker -from atst.forms.data import SERVICE_BRANCHES +from atst.forms import data from atst.models.environment import Environment from atst.models.request import Request from atst.models.request_revision import RequestRevision @@ -25,8 +25,12 @@ from atst.models.invitation import Invitation, Status as InvitationStatus from atst.domain.invitations import Invitations +def random_choice(choices): + return random.choice([k for k, v in choices if k]) + + def random_service_branch(): - return random.choice([k for k, v in SERVICE_BRANCHES if k]) + return random_choice(data.SERVICE_BRANCHES) class Base(factory.alchemy.SQLAlchemyModelFactory): @@ -352,9 +356,12 @@ class TaskOrderFactory(Base): class Meta: model = TaskOrder - clin_0001 = random.randrange(100, 100_000) - clin_0003 = random.randrange(100, 100_000) - clin_1001 = random.randrange(100, 100_000) - clin_1003 = random.randrange(100, 100_000) - clin_2001 = random.randrange(100, 100_000) - clin_2003 = random.randrange(100, 100_000) + clin_01 = random.randrange(100, 100_000) + clin_03 = random.randrange(100, 100_000) + + defense_component = random_service_branch() + app_migration = random_choice(data.APP_MIGRATION) + native_apps = "no" + complexity = random_choice(data.PROJECT_COMPLEXITY) + dev_team = random_choice(data.DEV_TEAM) + team_experience = random_choice(data.TEAM_EXPERIENCE) diff --git a/tests/routes/task_orders/test_edit_task_order.py b/tests/routes/task_orders/test_edit_task_order.py index cd097370..46ce2e5c 100644 --- a/tests/routes/task_orders/test_edit_task_order.py +++ b/tests/routes/task_orders/test_edit_task_order.py @@ -26,16 +26,9 @@ def test_create_new_workspace(client, user_session): response = client.post( url_for("task_orders.update", task_order_id=task_order.id), - data={ - "clin_0001": 12345, - "clin_0003": 12345, - "clin_1001": 12345, - "clin_1003": 12345, - "clin_2001": 12345, - "clin_2003": 12345, - }, + data={**TaskOrderFactory.dictionary(), "clin_01": 12345, "clin_03": 12345}, follow_redirects=False, ) assert response.status_code == 200 - assert task_order.clin_0001 == 12345 + assert task_order.clin_01 == 12345 From c6686d70e82411f3385cc99095ea500b41387c22 Mon Sep 17 00:00:00 2001 From: dandds Date: Mon, 17 Dec 2018 16:55:11 -0500 Subject: [PATCH 03/13] multistep task order workflow --- atst/domain/task_orders.py | 6 +- atst/forms/task_order.py | 22 +++- atst/models/task_order.py | 10 ++ atst/routes/task_orders/__init__.py | 132 ++++++++++++++++++++--- templates/task_orders/_new.html | 49 +++++++++ templates/task_orders/edit.html | 55 ++++------ templates/task_orders/new/app_info.html | 41 +++++++ templates/task_orders/new/funding.html | 29 +++++ templates/task_orders/new/menu.html | 21 ++++ templates/task_orders/new/oversight.html | 32 ++++++ 10 files changed, 339 insertions(+), 58 deletions(-) create mode 100644 templates/task_orders/_new.html create mode 100644 templates/task_orders/new/app_info.html create mode 100644 templates/task_orders/new/funding.html create mode 100644 templates/task_orders/new/menu.html create mode 100644 templates/task_orders/new/oversight.html diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 1f17e93a..294012fe 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -16,11 +16,13 @@ class TaskOrders(object): raise NotFoundError("task_order") @classmethod - def create(cls, workspace, creator): + def create(cls, workspace, creator, commit=False): task_order = TaskOrder(workspace=workspace, creator=creator) db.session.add(task_order) - db.session.commit() + + if commit: + db.session.commit() return task_order diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 19774824..86a7efb8 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -18,7 +18,11 @@ from .data import ( ) -class TaskOrderForm(CacheableForm): +class AppInfoForm(CacheableForm): + portfolio_name = StringField( + "Organization Portfolio Name", + description="The name of your office or organization. You can add multiple applications to your portfolio. Your task orders are used to pay for these applications and their environments", + ) scope = TextAreaField( "Cloud Project Scope", description="The name of your office or organization. You can add multiple applications to your portfolio. Your task orders are used to pay for these applications and their environments", @@ -45,20 +49,23 @@ class TaskOrderForm(CacheableForm): choices=PROJECT_COMPLEXITY, default="", ) - complexity_other = StringField("?") + complexity_other = StringField("Project Complexity Other") dev_team = SelectMultipleField( "Development Team", description="Which people or teams will be completing the development work for your cloud applications?", choices=DEV_TEAM, default="", ) - dev_team_other = StringField("?") + dev_team_other = StringField("Development Team Other") team_experience = RadioField( "Team Experience", description="How much experience does your team have with development in the cloud?", choices=TEAM_EXPERIENCE, default="", ) + + +class FundingForm(CacheableForm): start_date = DateField( "Period of Performance", description="Select a start and end date for your Task Order to be active. Please note, this will likely be revised once your Task Order has been approved.", @@ -80,6 +87,9 @@ class TaskOrderForm(CacheableForm): "CLIN 04: Classified Cloud Support and Assistance", description="CLASSIFIED technical guidance from the cloud service provider, including architecture, configuration of IaaS and PaaS, integration, troubleshooting assistance, and other services.", ) + + +class OversightForm(CacheableForm): ko_first_name = StringField("First Name") ko_last_name = StringField("Last Name") ko_email = StringField("Email") @@ -92,5 +102,7 @@ class TaskOrderForm(CacheableForm): so_last_name = StringField("Last Name") so_email = StringField("Email") so_dod_id = StringField("DOD ID") - number = StringField("Task Order Number") - loa = StringField("Line of Accounting (LOA)") + + +class ReviewForm(CacheableForm): + pass diff --git a/atst/models/task_order.py b/atst/models/task_order.py index b987f016..77f631d3 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -56,3 +56,13 @@ class TaskOrder(Base, mixins.TimestampsMixin): return "".format( self.number, self.budget, self.end_date, self.id ) + + def to_dictionary(self): + return { + "portfolio_name": self.workspace.name, + **{ + c.name: getattr(self, c.name) + for c in self.__table__.columns + if c.name not in ["id"] + }, + } diff --git a/atst/routes/task_orders/__init__.py b/atst/routes/task_orders/__init__.py index f20a6f1d..b192dbfe 100644 --- a/atst/routes/task_orders/__init__.py +++ b/atst/routes/task_orders/__init__.py @@ -1,26 +1,128 @@ -from flask import Blueprint, request as http_request, render_template +from flask import Blueprint, request as http_request, render_template, g, redirect, url_for from atst.domain.task_orders import TaskOrders -from atst.forms.task_order import TaskOrderForm +from atst.domain.workspaces import Workspaces +import atst.forms.task_order as task_order_form task_orders_bp = Blueprint("task_orders", __name__) -@task_orders_bp.route("/task_order/edit/") -def edit(task_order_id): - form = TaskOrderForm() - task_order = TaskOrders.get(task_order_id) - return render_template("task_orders/edit.html", form=form, task_order=task_order) +TASK_ORDER_SECTIONS = [ + { + "section": "app_info", + "title": "What You're Building", + "template": "task_orders/new/app_info.html", + "form": task_order_form.AppInfoForm, + }, + { + "section": "funding", + "title": "Funding", + "template": "task_orders/new/funding.html", + "form": task_order_form.FundingForm, + }, + { + "section": "oversight", + "title": "Oversight", + "template": "task_orders/new/oversight.html", + "form": task_order_form.OversightForm, + }, + { + "section": "review", + "title": "Review & Download", + "template": "task_orders/new/review.html", + "form": task_order_form.ReviewForm, + }, +] -@task_orders_bp.route("/task_order/edit/", methods=["POST"]) -def update(task_order_id): - form = TaskOrderForm(http_request.form) - task_order = TaskOrders.get(task_order_id) - if form.validate(): - TaskOrders.update(task_order, **form.data) - return "i did it" +class ShowTaskOrderWorkflow: + def __init__(self, screen=1, task_order_id=None): + self.screen = screen + self.task_order_id = task_order_id + self._section = TASK_ORDER_SECTIONS[screen - 1] + self._task_order = None + self._form = None + + @property + def task_order(self): + if not self._task_order and self.task_order_id: + self._task_order = TaskOrders.get(self.task_order_id) + + return self._task_order + + @property + def form(self): + if self._form: + pass + elif self.task_order: + self._form = self._section["form"](data=self.task_order.to_dictionary()) + else: + self._form = self._section["form"]() + + return self._form + + @property + def template(self): + return self._section["template"] + + +class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): + def __init__(self, form_data, user, screen=1, task_order_id=None): + self.form_data = form_data + self.user = user + self.screen = screen + self.task_order_id = task_order_id + self._task_order = None + self._section = TASK_ORDER_SECTIONS[screen - 1] + + @property + def form(self): + return self._section["form"](self.form_data) + + def validate(self): + return self.form.validate() + + def update(self): + if self.task_order: + TaskOrders.update(self.task_order, **self.form.data) + else: + ws = Workspaces.create(self.user, self.form.portfolio_name.data) + to_data = self.form.data.copy() + to_data.pop("portfolio_name") + self._task_order = TaskOrders.create(workspace=ws, creator=self.user) + TaskOrders.update(self.task_order, **to_data) + + return self.task_order + + +@task_orders_bp.route("/task_order/new/") +@task_orders_bp.route("/task_order/new//") +def new(screen, task_order_id=None): + workflow = ShowTaskOrderWorkflow(screen, task_order_id) + return render_template( + workflow.template, + current=screen, + task_order_id=task_order_id, + screens=TASK_ORDER_SECTIONS, + form=workflow.form, + ) + + +@task_orders_bp.route("/task_order/new/", methods=["POST"]) +@task_orders_bp.route("/task_order/new//", methods=["POST"]) +def update(screen, task_order_id=None): + workflow = UpdateTaskOrderWorkflow( + http_request.form, g.current_user, screen, task_order_id + ) + + if workflow.validate(): + workflow.update() + return redirect(url_for("task_orders.new", screen=screen+1, task_order_id=workflow.task_order.id)) else: return render_template( - "task_orders/edit.html", form=form, task_order=task_order + workflow.template, + current=screen, + task_order_id=task_order_id, + screens=TASK_ORDER_SECTIONS, + form=workflow.form, ) diff --git a/templates/task_orders/_new.html b/templates/task_orders/_new.html new file mode 100644 index 00000000..1f406b7f --- /dev/null +++ b/templates/task_orders/_new.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block content %} + +
+ + {% include 'task_orders/new/menu.html' %} + + {% include "fragments/flash.html" %} + + {% block form_action %} + {% if task_order_id %} +
+ {% else %} + + {% endif %} + {% endblock %} + +
+ +
+
Task Order Builder
+

{% block heading %}{% endblock %}

+
+ +
+ + {{ form.csrf_token }} + {% block form %} + form goes here + {% endblock %} + +
+ +
+ + {% block next %} + +
+ +
+ + {% endblock %} + +
+ +
+ +{% endblock %} diff --git a/templates/task_orders/edit.html b/templates/task_orders/edit.html index 95ac6f03..a0e3c4b2 100644 --- a/templates/task_orders/edit.html +++ b/templates/task_orders/edit.html @@ -18,43 +18,26 @@
- {{ TextInput(form.scope, paragraph=True) }} - {{ OptionsInput(form.defense_component) }} - {{ OptionsInput(form.app_migration) }} - {{ OptionsInput(form.native_apps) }} - {{ OptionsInput(form.complexity) }} - {{ TextInput(form.complexity_other) }} - {{ OptionsInput(form.dev_team) }} - {{ TextInput(form.dev_team_other) }} - {{ OptionsInput(form.team_experience) }} - {{ DateInput(form.start_date, placeholder='MM / DD / YYYY', validation='date') }} - {{ DateInput(form.end_date, placeholder='MM / DD / YYYY', validation='date') }} - {{ TextInput(form.clin_01, validation='dollars') }} - {{ TextInput(form.clin_02, validation='dollars') }} - {{ TextInput(form.clin_03, validation='dollars') }} - {{ TextInput(form.clin_04, validation='dollars') }} -

Contracting Officer (KO) Information

- {{ TextInput(form.ko_first_name) }} - {{ TextInput(form.ko_last_name) }} - {{ TextInput(form.ko_email) }} - {{ TextInput(form.ko_dod_id) }} -

Contractive Officer Representative (COR) Information

- {{ TextInput(form.cor_first_name) }} - {{ TextInput(form.cor_last_name) }} - {{ TextInput(form.cor_email) }} - {{ TextInput(form.cor_dod_id) }} -

Security Officer Information

- {{ TextInput(form.so_first_name) }} - {{ TextInput(form.so_last_name) }} - {{ TextInput(form.so_email) }} - {{ TextInput(form.so_dod_id) }} - {{ TextInput(form.number) }} - {{ TextInput(form.loa) }} -
- +

DoD Contract Security Classification Specification

-
- + + +
+ +
+ +

Download your Task Order Packet.

+ + + + + {{ TextInput(form.number) }} + {{ TextInput(form.number_confirm) }} + {{ TextInput(form.loa) }} +

Add another LOA

+ +

I certify that the task order information above is accurate and that funding has been allocated to the above task order.

+
diff --git a/templates/task_orders/new/app_info.html b/templates/task_orders/new/app_info.html new file mode 100644 index 00000000..86acc4e7 --- /dev/null +++ b/templates/task_orders/new/app_info.html @@ -0,0 +1,41 @@ +{% extends 'task_orders/_new.html' %} + +{% from "components/text_input.html" import TextInput %} +{% from "components/options_input.html" import OptionsInput %} +{% from "components/date_input.html" import DateInput %} + +{% block heading %} + What You're Building +{% endblock %} + +{% block form %} + +{% include "fragments/flash.html" %} + +

Basic Information

+{{ TextInput(form.portfolio_name) }} +{{ TextInput(form.scope, paragraph=True) }} +{{ OptionsInput(form.defense_component) }} + +
+ +

About Your Project

+{{ OptionsInput(form.app_migration) }} +{{ OptionsInput(form.native_apps) }} +{{ OptionsInput(form.complexity) }} +{{ TextInput(form.complexity_other) }} + +
+ +

About Your Team

+{{ OptionsInput(form.dev_team) }} +{{ TextInput(form.dev_team_other) }} +{{ OptionsInput(form.team_experience) }} + +
+ +

Market Research

+

View JEDI Market Research Memo

+ + +{% endblock %} diff --git a/templates/task_orders/new/funding.html b/templates/task_orders/new/funding.html new file mode 100644 index 00000000..a5189b4b --- /dev/null +++ b/templates/task_orders/new/funding.html @@ -0,0 +1,29 @@ +{% extends 'task_orders/_new.html' %} + +{% from "components/text_input.html" import TextInput %} +{% from "components/options_input.html" import OptionsInput %} +{% from "components/date_input.html" import DateInput %} + +{% block heading %} + Funding +{% endblock %} + +{% block form %} + +{% include "fragments/flash.html" %} + + +{{ DateInput(form.start_date, placeholder='MM / DD / YYYY', validation='date') }} +{{ DateInput(form.end_date, placeholder='MM / DD / YYYY', validation='date') }} +

Cloud Usage Estimate

+

Upload a copy of your CSP Cost Estimate Research

+ +

Cloud Usage Calculations

+{{ TextInput(form.clin_01, validation='dollars') }} +{{ TextInput(form.clin_02, validation='dollars') }} +{{ TextInput(form.clin_03, validation='dollars', tooltip='The cloud support and assistance packages cannot be used as a primary development resource.') }} +{{ TextInput(form.clin_04, validation='dollars', tooltip='The cloud support and assistance packages cannot be used as a primary development resource.') }} +

Total Task Order Value

+ + +{% endblock %} diff --git a/templates/task_orders/new/menu.html b/templates/task_orders/new/menu.html new file mode 100644 index 00000000..7582a7bd --- /dev/null +++ b/templates/task_orders/new/menu.html @@ -0,0 +1,21 @@ +
+
    + {% for s in screens %} + {% if jedi_request and s.section in jedi_request.body %} + {% set step_indicator = 'complete' %} + {% elif loop.index == current %} + {% set step_indicator = 'active' %} + {% else %} + {% set step_indicator = 'incomplete' %} + {% endif %} + +
  • + + {{ s['title'] }} + +
  • + {% endfor %} +
+
diff --git a/templates/task_orders/new/oversight.html b/templates/task_orders/new/oversight.html new file mode 100644 index 00000000..f48c3d2c --- /dev/null +++ b/templates/task_orders/new/oversight.html @@ -0,0 +1,32 @@ +{% extends 'task_orders/_new.html' %} + +{% from "components/text_input.html" import TextInput %} +{% from "components/options_input.html" import OptionsInput %} +{% from "components/date_input.html" import DateInput %} + +{% block heading %} + Funding +{% endblock %} + +{% block form %} + +{% include "fragments/flash.html" %} + + +

Contracting Officer (KO) Information

+{{ TextInput(form.ko_first_name) }} +{{ TextInput(form.ko_last_name) }} +{{ TextInput(form.ko_email) }} +{{ TextInput(form.ko_dod_id) }} +

Contractive Officer Representative (COR) Information

+{{ TextInput(form.cor_first_name) }} +{{ TextInput(form.cor_last_name) }} +{{ TextInput(form.cor_email) }} +{{ TextInput(form.cor_dod_id) }} +

Security Officer Information

+{{ TextInput(form.so_first_name) }} +{{ TextInput(form.so_last_name) }} +{{ TextInput(form.so_email) }} +{{ TextInput(form.so_dod_id) }} + +{% endblock %} From 9a12c14636cd85b14c1df6b25aa742a10b43cb32 Mon Sep 17 00:00:00 2001 From: dandds Date: Tue, 18 Dec 2018 10:38:51 -0500 Subject: [PATCH 04/13] domain methods for checking task order sections --- atst/domain/task_orders.py | 56 +++++++++++++++++++ atst/routes/task_orders/__init__.py | 22 ++++++-- templates/task_orders/new/menu.html | 2 +- tests/domain/test_legacy_task_orders.py | 28 ++++++++++ tests/domain/test_task_orders.py | 43 +++++++------- ...t_task_order.py => test_new_task_order.py} | 9 +-- 6 files changed, 128 insertions(+), 32 deletions(-) create mode 100644 tests/domain/test_legacy_task_orders.py rename tests/routes/task_orders/{test_edit_task_order.py => test_new_task_order.py} (71%) diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 294012fe..e3b2c342 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -6,6 +6,42 @@ from .exceptions import NotFoundError class TaskOrders(object): + SECTIONS = { + "app_info": [ + "scope", + "defense_component", + "app_migration", + "native_apps", + "complexity", + "complexity_other", + "dev_team", + "dev_team_other", + "team_experience", + ], + "funding": [ + "start_date", + "end_date", + "clin_01", + "clin_02", + "clin_03", + "clin_04", + ], + "oversight": [ + "ko_first_name", + "ko_last_name", + "ko_email", + "ko_dod_id", + "cor_first_name", + "cor_last_name", + "cor_email", + "cor_dod_id", + "so_first_name", + "so_last_name", + "so_email", + "so_dod_id", + ], + } + @classmethod def get(cls, task_order_id): try: @@ -35,3 +71,23 @@ class TaskOrders(object): db.session.commit() return task_order + + @classmethod + def is_section_complete(cls, task_order, section): + if section in TaskOrders.SECTIONS: + for attr in TaskOrders.SECTIONS[section]: + if not getattr(task_order, attr): + return False + + return True + + else: + return False + + @classmethod + def all_sections_complete(cls, task_order): + for section in TaskOrders.SECTIONS.keys(): + if not TaskOrders.is_section_complete(task_order, section): + return False + + return True diff --git a/atst/routes/task_orders/__init__.py b/atst/routes/task_orders/__init__.py index b192dbfe..bacbd484 100644 --- a/atst/routes/task_orders/__init__.py +++ b/atst/routes/task_orders/__init__.py @@ -65,6 +65,18 @@ class ShowTaskOrderWorkflow: def template(self): return self._section["template"] + @property + def display_screens(self): + screen_info = TASK_ORDER_SECTIONS.copy() + + if self.task_order: + for section in screen_info: + if TaskOrders.is_section_complete(self.task_order, section["section"]): + section["complete"] = True + + return screen_info + + class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): def __init__(self, form_data, user, screen=1, task_order_id=None): @@ -95,21 +107,21 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): return self.task_order -@task_orders_bp.route("/task_order/new/") -@task_orders_bp.route("/task_order/new//") +@task_orders_bp.route("/task_orders/new/") +@task_orders_bp.route("/task_orders/new//") def new(screen, task_order_id=None): workflow = ShowTaskOrderWorkflow(screen, task_order_id) return render_template( workflow.template, current=screen, task_order_id=task_order_id, - screens=TASK_ORDER_SECTIONS, + screens=workflow.display_screens, form=workflow.form, ) -@task_orders_bp.route("/task_order/new/", methods=["POST"]) -@task_orders_bp.route("/task_order/new//", methods=["POST"]) +@task_orders_bp.route("/task_orders/new/", methods=["POST"]) +@task_orders_bp.route("/task_orders/new//", methods=["POST"]) def update(screen, task_order_id=None): workflow = UpdateTaskOrderWorkflow( http_request.form, g.current_user, screen, task_order_id diff --git a/templates/task_orders/new/menu.html b/templates/task_orders/new/menu.html index 7582a7bd..19550e13 100644 --- a/templates/task_orders/new/menu.html +++ b/templates/task_orders/new/menu.html @@ -1,7 +1,7 @@
    {% for s in screens %} - {% if jedi_request and s.section in jedi_request.body %} + {% if s.complete %} {% set step_indicator = 'complete' %} {% elif loop.index == current %} {% set step_indicator = 'active' %} diff --git a/tests/domain/test_legacy_task_orders.py b/tests/domain/test_legacy_task_orders.py new file mode 100644 index 00000000..5defb88e --- /dev/null +++ b/tests/domain/test_legacy_task_orders.py @@ -0,0 +1,28 @@ +import pytest + +from atst.domain.exceptions import NotFoundError +from atst.domain.legacy_task_orders import LegacyTaskOrders +from atst.eda_client import MockEDAClient + +from tests.factories import LegacyTaskOrderFactory + + +def test_can_get_task_order(): + new_to = LegacyTaskOrderFactory.create(number="0101969F") + to = LegacyTaskOrders.get(new_to.number) + + assert to.id == to.id + + +def test_nonexistent_task_order_raises_without_client(): + with pytest.raises(NotFoundError): + LegacyTaskOrders.get("some fake number") + + +def test_nonexistent_task_order_raises_with_client(monkeypatch): + monkeypatch.setattr( + "atst.domain.legacy_task_orders.LegacyTaskOrders._client", + lambda: MockEDAClient(), + ) + with pytest.raises(NotFoundError): + LegacyTaskOrders.get("some other fake numer") diff --git a/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index 5defb88e..67d0b040 100644 --- a/tests/domain/test_task_orders.py +++ b/tests/domain/test_task_orders.py @@ -1,28 +1,31 @@ import pytest -from atst.domain.exceptions import NotFoundError -from atst.domain.legacy_task_orders import LegacyTaskOrders -from atst.eda_client import MockEDAClient +from atst.domain.task_orders import TaskOrders -from tests.factories import LegacyTaskOrderFactory +from tests.factories import TaskOrderFactory -def test_can_get_task_order(): - new_to = LegacyTaskOrderFactory.create(number="0101969F") - to = LegacyTaskOrders.get(new_to.number) - - assert to.id == to.id +def test_is_section_complete(): + dict_keys = [k for k in TaskOrders.SECTIONS.keys()] + section = dict_keys[0] + attrs = TaskOrders.SECTIONS[section].copy() + task_order = TaskOrderFactory.create(**{k: None for k in attrs}) + leftover = attrs.pop() + for attr in attrs: + setattr(task_order, attr, "str12345") + assert not TaskOrders.is_section_complete(task_order, section) + setattr(task_order, leftover, "str12345") + assert TaskOrders.is_section_complete(task_order, section) -def test_nonexistent_task_order_raises_without_client(): - with pytest.raises(NotFoundError): - LegacyTaskOrders.get("some fake number") +def test_all_sections_complete(): + task_order = TaskOrderFactory.create() + for attr_list in TaskOrders.SECTIONS.values(): + for attr in attr_list: + if not getattr(task_order, attr): + setattr(task_order, attr, "str12345") - -def test_nonexistent_task_order_raises_with_client(monkeypatch): - monkeypatch.setattr( - "atst.domain.legacy_task_orders.LegacyTaskOrders._client", - lambda: MockEDAClient(), - ) - with pytest.raises(NotFoundError): - LegacyTaskOrders.get("some other fake numer") + task_order.scope = None + assert not TaskOrders.all_sections_complete(task_order) + task_order.scope = "str12345" + assert TaskOrders.all_sections_complete(task_order) diff --git a/tests/routes/task_orders/test_edit_task_order.py b/tests/routes/task_orders/test_new_task_order.py similarity index 71% rename from tests/routes/task_orders/test_edit_task_order.py rename to tests/routes/task_orders/test_new_task_order.py index 46ce2e5c..484a3da9 100644 --- a/tests/routes/task_orders/test_edit_task_order.py +++ b/tests/routes/task_orders/test_new_task_order.py @@ -7,17 +7,14 @@ from atst.models.workspace import Workspace from tests.factories import UserFactory, WorkspaceFactory, TaskOrderFactory -def test_edit_task_order(client, user_session): +def test_new_task_order(client, user_session): creator = UserFactory.create() - task_order = TaskOrderFactory.create( - creator=creator, workspace=WorkspaceFactory.create() - ) user_session() - response = client.get(url_for("task_orders.edit", task_order_id=task_order.id)) + response = client.get(url_for("task_orders.new", screen=1)) assert response.status_code == 200 -def test_create_new_workspace(client, user_session): +def test_create_new_task_order(client, user_session): creator = UserFactory.create() task_order = TaskOrderFactory.create( creator=creator, workspace=WorkspaceFactory.create() From ad03b58deeb3e88123c98db0e520de25bf672a14 Mon Sep 17 00:00:00 2001 From: dandds Date: Tue, 18 Dec 2018 13:22:54 -0500 Subject: [PATCH 05/13] tests for new task order endpoints, remove new workspace routes --- atst/routes/task_orders/__init__.py | 18 +++++-- atst/routes/workspaces/__init__.py | 1 - atst/routes/workspaces/new.py | 23 -------- templates/navigation/global_navigation.html | 11 ++-- tests/factories.py | 50 +++++++++++++---- .../routes/task_orders/test_new_task_order.py | 54 +++++++++++++------ tests/routes/workspaces/test_new_workspace.py | 27 ---------- 7 files changed, 97 insertions(+), 87 deletions(-) delete mode 100644 atst/routes/workspaces/new.py delete mode 100644 tests/routes/workspaces/test_new_workspace.py diff --git a/atst/routes/task_orders/__init__.py b/atst/routes/task_orders/__init__.py index bacbd484..5e203771 100644 --- a/atst/routes/task_orders/__init__.py +++ b/atst/routes/task_orders/__init__.py @@ -1,4 +1,11 @@ -from flask import Blueprint, request as http_request, render_template, g, redirect, url_for +from flask import ( + Blueprint, + request as http_request, + render_template, + g, + redirect, + url_for, +) from atst.domain.task_orders import TaskOrders from atst.domain.workspaces import Workspaces @@ -77,7 +84,6 @@ class ShowTaskOrderWorkflow: return screen_info - class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): def __init__(self, form_data, user, screen=1, task_order_id=None): self.form_data = form_data @@ -129,7 +135,13 @@ def update(screen, task_order_id=None): if workflow.validate(): workflow.update() - return redirect(url_for("task_orders.new", screen=screen+1, task_order_id=workflow.task_order.id)) + return redirect( + url_for( + "task_orders.new", + screen=screen + 1, + task_order_id=workflow.task_order.id, + ) + ) else: return render_template( workflow.template, diff --git a/atst/routes/workspaces/__init__.py b/atst/routes/workspaces/__init__.py index eb416c6b..6ba95d77 100644 --- a/atst/routes/workspaces/__init__.py +++ b/atst/routes/workspaces/__init__.py @@ -6,7 +6,6 @@ from . import index from . import projects from . import members from . import invitations -from . import new from atst.domain.exceptions import UnauthorizedError from atst.domain.workspaces import Workspaces from atst.domain.authz import Authorization diff --git a/atst/routes/workspaces/new.py b/atst/routes/workspaces/new.py deleted file mode 100644 index 55895ab4..00000000 --- a/atst/routes/workspaces/new.py +++ /dev/null @@ -1,23 +0,0 @@ -from flask import g, redirect, url_for, render_template, request as http_request - -from . import workspaces_bp -from atst.domain.task_orders import TaskOrders -from atst.domain.workspaces import Workspaces -from atst.forms.workspace import WorkspaceForm - - -@workspaces_bp.route("/workspaces/new") -def new(): - form = WorkspaceForm() - return render_template("workspaces/new.html", form=form) - - -@workspaces_bp.route("/workspaces/new", methods=["POST"]) -def create(): - form = WorkspaceForm(http_request.form) - if form.validate(): - ws = Workspaces.create(g.current_user, form.name.data) - task_order = TaskOrders.create(workspace=ws, creator=g.current_user) - return redirect(url_for("task_orders.edit", task_order_id=task_order.id)) - else: - return render_template("workspaces/new.html", form=form) diff --git a/templates/navigation/global_navigation.html b/templates/navigation/global_navigation.html index 89952e96..093b86bd 100644 --- a/templates/navigation/global_navigation.html +++ b/templates/navigation/global_navigation.html @@ -11,14 +11,9 @@ ] ) }} - {{ SidenavItem("Workspaces", - href="/workspaces", - icon="cloud", - active=g.matchesPath('/workspaces'), - subnav=[ - {"label":"New Workspace", "href":url_for("workspaces.new"), "icon": "plus", "active": g.matchesPath('/workspaces/new')}, - ] - ) }} + {% if g.current_user.has_workspaces %} + {{ SidenavItem("Workspaces", href="/workspaces", icon="cloud", active=g.matchesPath('/workspaces')) }} + {% endif %} {% if g.Authorization.has_atat_permission(g.current_user, g.Permissions.VIEW_AUDIT_LOG) %} {{ SidenavItem("Activity History", url_for('atst.activity_history'), icon="time", active=g.matchesPath('/activity-history')) }} diff --git a/tests/factories.py b/tests/factories.py index 2ca7d389..e6106b83 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -33,6 +33,23 @@ def random_service_branch(): return random_choice(data.SERVICE_BRANCHES) +def random_dod_id(): + return "".join(random.choices(string.digits, k=10)) + + +def random_future_date(year_min=1, year_max=5): + if year_min == year_max: + inc = year_min + else: + inc = random.randrange(year_min, year_max) + + return datetime.date( + datetime.date.today().year + inc, + random.randrange(1, 12), + random.randrange(1, 28), + ) + + class Base(factory.alchemy.SQLAlchemyModelFactory): @classmethod def dictionary(cls, **attrs): @@ -58,7 +75,7 @@ class UserFactory(Base): first_name = factory.Faker("first_name") last_name = factory.Faker("last_name") atat_role = factory.SubFactory(RoleFactory) - dod_id = factory.LazyFunction(lambda: "".join(random.choices(string.digits, k=10))) + dod_id = factory.LazyFunction(random_dod_id) phone_number = factory.LazyFunction( lambda: "".join(random.choices(string.digits, k=10)) ) @@ -227,13 +244,7 @@ class LegacyTaskOrderFactory(Base): number = factory.LazyFunction( lambda: "".join(random.choices(string.ascii_uppercase + string.digits, k=13)) ) - expiration_date = factory.LazyFunction( - lambda: datetime.date( - datetime.date.today().year + random.randrange(1, 5), - random.randrange(1, 12), - random.randrange(1, 28), - ) - ) + expiration_date = factory.LazyFunction(random_future_date) clin_0001 = random.randrange(100, 100_000) clin_0003 = random.randrange(100, 100_000) clin_1001 = random.randrange(100, 100_000) @@ -358,10 +369,29 @@ class TaskOrderFactory(Base): clin_01 = random.randrange(100, 100_000) clin_03 = random.randrange(100, 100_000) + clin_02 = random.randrange(100, 100_000) + clin_04 = random.randrange(100, 100_000) - defense_component = random_service_branch() + defense_component = factory.LazyFunction(random_service_branch) app_migration = random_choice(data.APP_MIGRATION) - native_apps = "no" + native_apps = random.choices(["yes", "no", "not_sure"]) complexity = random_choice(data.PROJECT_COMPLEXITY) dev_team = random_choice(data.DEV_TEAM) team_experience = random_choice(data.TEAM_EXPERIENCE) + + scope = factory.Faker("sentence") + start_date = random_future_date(year_min=1, year_max=1) + end_date = random_future_date(year_min=2, year_max=5) + + ko_first_name = factory.Faker("first_name") + ko_last_name = factory.Faker("last_name") + ko_email = factory.Faker("email") + ko_dod_id = factory.LazyFunction(random_dod_id) + cor_first_name = factory.Faker("first_name") + cor_last_name = factory.Faker("last_name") + cor_email = factory.Faker("email") + cor_dod_id = factory.LazyFunction(random_dod_id) + so_first_name = factory.Faker("first_name") + so_last_name = factory.Faker("last_name") + so_email = factory.Faker("email") + so_dod_id = factory.LazyFunction(random_dod_id) diff --git a/tests/routes/task_orders/test_new_task_order.py b/tests/routes/task_orders/test_new_task_order.py index 484a3da9..182fe4bb 100644 --- a/tests/routes/task_orders/test_new_task_order.py +++ b/tests/routes/task_orders/test_new_task_order.py @@ -1,10 +1,9 @@ import pytest from flask import url_for -from atst.database import db -from atst.models.workspace import Workspace +from atst.domain.task_orders import TaskOrders -from tests.factories import UserFactory, WorkspaceFactory, TaskOrderFactory +from tests.factories import UserFactory, TaskOrderFactory def test_new_task_order(client, user_session): @@ -14,18 +13,43 @@ def test_new_task_order(client, user_session): assert response.status_code == 200 -def test_create_new_task_order(client, user_session): - creator = UserFactory.create() - task_order = TaskOrderFactory.create( - creator=creator, workspace=WorkspaceFactory.create() - ) - user_session() - - response = client.post( - url_for("task_orders.update", task_order_id=task_order.id), - data={**TaskOrderFactory.dictionary(), "clin_01": 12345, "clin_03": 12345}, +def post_to_task_order_step(client, data, screen, task_order_id=None): + return client.post( + url_for("task_orders.update", screen=screen, task_order_id=task_order_id), + data=data, follow_redirects=False, ) - assert response.status_code == 200 - assert task_order.clin_01 == 12345 + +def slice_data_for_section(task_order_data, section): + attrs = TaskOrders.SECTIONS[section] + return {k: v for k, v in task_order_data.items() if k in attrs} + + +# TODO: this test will need to be more complicated when we add validation to +# the forms +def test_create_new_task_order(client, user_session): + creator = UserFactory.create() + user_session(creator) + + task_order_data = TaskOrderFactory.dictionary() + app_info_data = slice_data_for_section(task_order_data, "app_info") + + response = client.post( + url_for("task_orders.update", screen=1), + data=app_info_data, + follow_redirects=False, + ) + assert url_for("task_orders.new", screen=2) in response.headers["Location"] + + funding_data = slice_data_for_section(task_order_data, "funding") + response = client.post( + response.headers["Location"], data=funding_data, follow_redirects=False + ) + assert url_for("task_orders.new", screen=3) in response.headers["Location"] + + oversight_data = slice_data_for_section(task_order_data, "oversight") + response = client.post( + response.headers["Location"], data=oversight_data, follow_redirects=False + ) + assert url_for("task_orders.new", screen=4) in response.headers["Location"] diff --git a/tests/routes/workspaces/test_new_workspace.py b/tests/routes/workspaces/test_new_workspace.py deleted file mode 100644 index 2846ea56..00000000 --- a/tests/routes/workspaces/test_new_workspace.py +++ /dev/null @@ -1,27 +0,0 @@ -from flask import url_for - -from atst.database import db -from atst.models.workspace import Workspace - - -def get_workspace_by_name(name): - return db.session.query(Workspace).filter_by(name=name).one() - - -def test_get_new_workspace(client, user_session): - user_session() - response = client.get(url_for("workspaces.new")) - assert response.status_code == 200 - - -def test_create_new_workspace(client, user_session): - user_session() - ws_name = "mos-eisley" - response = client.post( - url_for("workspaces.create"), data={"name": ws_name}, follow_redirects=False - ) - assert response.status_code == 302 - workspace = get_workspace_by_name(ws_name) - assert workspace.name == ws_name - task_order = workspace.task_orders[0] - assert str(task_order.id) in response.headers.get("Location") From 5cc5b700b7373f751e6e838556c5947c2c398e12 Mon Sep 17 00:00:00 2001 From: dandds Date: Tue, 18 Dec 2018 13:32:51 -0500 Subject: [PATCH 06/13] move task order routes file --- atst/routes/task_orders/__init__.py | 151 +--------------------------- atst/routes/task_orders/new.py | 144 ++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 149 deletions(-) create mode 100644 atst/routes/task_orders/new.py diff --git a/atst/routes/task_orders/__init__.py b/atst/routes/task_orders/__init__.py index 5e203771..2de14f43 100644 --- a/atst/routes/task_orders/__init__.py +++ b/atst/routes/task_orders/__init__.py @@ -1,152 +1,5 @@ -from flask import ( - Blueprint, - request as http_request, - render_template, - g, - redirect, - url_for, -) - -from atst.domain.task_orders import TaskOrders -from atst.domain.workspaces import Workspaces -import atst.forms.task_order as task_order_form +from flask import Blueprint task_orders_bp = Blueprint("task_orders", __name__) - -TASK_ORDER_SECTIONS = [ - { - "section": "app_info", - "title": "What You're Building", - "template": "task_orders/new/app_info.html", - "form": task_order_form.AppInfoForm, - }, - { - "section": "funding", - "title": "Funding", - "template": "task_orders/new/funding.html", - "form": task_order_form.FundingForm, - }, - { - "section": "oversight", - "title": "Oversight", - "template": "task_orders/new/oversight.html", - "form": task_order_form.OversightForm, - }, - { - "section": "review", - "title": "Review & Download", - "template": "task_orders/new/review.html", - "form": task_order_form.ReviewForm, - }, -] - - -class ShowTaskOrderWorkflow: - def __init__(self, screen=1, task_order_id=None): - self.screen = screen - self.task_order_id = task_order_id - self._section = TASK_ORDER_SECTIONS[screen - 1] - self._task_order = None - self._form = None - - @property - def task_order(self): - if not self._task_order and self.task_order_id: - self._task_order = TaskOrders.get(self.task_order_id) - - return self._task_order - - @property - def form(self): - if self._form: - pass - elif self.task_order: - self._form = self._section["form"](data=self.task_order.to_dictionary()) - else: - self._form = self._section["form"]() - - return self._form - - @property - def template(self): - return self._section["template"] - - @property - def display_screens(self): - screen_info = TASK_ORDER_SECTIONS.copy() - - if self.task_order: - for section in screen_info: - if TaskOrders.is_section_complete(self.task_order, section["section"]): - section["complete"] = True - - return screen_info - - -class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): - def __init__(self, form_data, user, screen=1, task_order_id=None): - self.form_data = form_data - self.user = user - self.screen = screen - self.task_order_id = task_order_id - self._task_order = None - self._section = TASK_ORDER_SECTIONS[screen - 1] - - @property - def form(self): - return self._section["form"](self.form_data) - - def validate(self): - return self.form.validate() - - def update(self): - if self.task_order: - TaskOrders.update(self.task_order, **self.form.data) - else: - ws = Workspaces.create(self.user, self.form.portfolio_name.data) - to_data = self.form.data.copy() - to_data.pop("portfolio_name") - self._task_order = TaskOrders.create(workspace=ws, creator=self.user) - TaskOrders.update(self.task_order, **to_data) - - return self.task_order - - -@task_orders_bp.route("/task_orders/new/") -@task_orders_bp.route("/task_orders/new//") -def new(screen, task_order_id=None): - workflow = ShowTaskOrderWorkflow(screen, task_order_id) - return render_template( - workflow.template, - current=screen, - task_order_id=task_order_id, - screens=workflow.display_screens, - form=workflow.form, - ) - - -@task_orders_bp.route("/task_orders/new/", methods=["POST"]) -@task_orders_bp.route("/task_orders/new//", methods=["POST"]) -def update(screen, task_order_id=None): - workflow = UpdateTaskOrderWorkflow( - http_request.form, g.current_user, screen, task_order_id - ) - - if workflow.validate(): - workflow.update() - return redirect( - url_for( - "task_orders.new", - screen=screen + 1, - task_order_id=workflow.task_order.id, - ) - ) - else: - return render_template( - workflow.template, - current=screen, - task_order_id=task_order_id, - screens=TASK_ORDER_SECTIONS, - form=workflow.form, - ) +from . import new diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py new file mode 100644 index 00000000..507f2471 --- /dev/null +++ b/atst/routes/task_orders/new.py @@ -0,0 +1,144 @@ +from flask import request as http_request, render_template, g, redirect, url_for + +from . import task_orders_bp +from atst.domain.task_orders import TaskOrders +from atst.domain.workspaces import Workspaces +import atst.forms.task_order as task_order_form + + +TASK_ORDER_SECTIONS = [ + { + "section": "app_info", + "title": "What You're Building", + "template": "task_orders/new/app_info.html", + "form": task_order_form.AppInfoForm, + }, + { + "section": "funding", + "title": "Funding", + "template": "task_orders/new/funding.html", + "form": task_order_form.FundingForm, + }, + { + "section": "oversight", + "title": "Oversight", + "template": "task_orders/new/oversight.html", + "form": task_order_form.OversightForm, + }, + { + "section": "review", + "title": "Review & Download", + "template": "task_orders/new/review.html", + "form": task_order_form.ReviewForm, + }, +] + + +class ShowTaskOrderWorkflow: + def __init__(self, screen=1, task_order_id=None): + self.screen = screen + self.task_order_id = task_order_id + self._section = TASK_ORDER_SECTIONS[screen - 1] + self._task_order = None + self._form = None + + @property + def task_order(self): + if not self._task_order and self.task_order_id: + self._task_order = TaskOrders.get(self.task_order_id) + + return self._task_order + + @property + def form(self): + if self._form: + pass + elif self.task_order: + self._form = self._section["form"](data=self.task_order.to_dictionary()) + else: + self._form = self._section["form"]() + + return self._form + + @property + def template(self): + return self._section["template"] + + @property + def display_screens(self): + screen_info = TASK_ORDER_SECTIONS.copy() + + if self.task_order: + for section in screen_info: + if TaskOrders.is_section_complete(self.task_order, section["section"]): + section["complete"] = True + + return screen_info + + +class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): + def __init__(self, form_data, user, screen=1, task_order_id=None): + self.form_data = form_data + self.user = user + self.screen = screen + self.task_order_id = task_order_id + self._task_order = None + self._section = TASK_ORDER_SECTIONS[screen - 1] + + @property + def form(self): + return self._section["form"](self.form_data) + + def validate(self): + return self.form.validate() + + def update(self): + if self.task_order: + TaskOrders.update(self.task_order, **self.form.data) + else: + ws = Workspaces.create(self.user, self.form.portfolio_name.data) + to_data = self.form.data.copy() + to_data.pop("portfolio_name") + self._task_order = TaskOrders.create(workspace=ws, creator=self.user) + TaskOrders.update(self.task_order, **to_data) + + return self.task_order + + +@task_orders_bp.route("/task_orders/new/") +@task_orders_bp.route("/task_orders/new//") +def new(screen, task_order_id=None): + workflow = ShowTaskOrderWorkflow(screen, task_order_id) + return render_template( + workflow.template, + current=screen, + task_order_id=task_order_id, + screens=workflow.display_screens, + form=workflow.form, + ) + + +@task_orders_bp.route("/task_orders/new/", methods=["POST"]) +@task_orders_bp.route("/task_orders/new//", methods=["POST"]) +def update(screen, task_order_id=None): + workflow = UpdateTaskOrderWorkflow( + http_request.form, g.current_user, screen, task_order_id + ) + + if workflow.validate(): + workflow.update() + return redirect( + url_for( + "task_orders.new", + screen=screen + 1, + task_order_id=workflow.task_order.id, + ) + ) + else: + return render_template( + workflow.template, + current=screen, + task_order_id=task_order_id, + screens=TASK_ORDER_SECTIONS, + form=workflow.form, + ) From c8da258d33329fb2926c2c5c314e9f140b1a7753 Mon Sep 17 00:00:00 2001 From: dandds Date: Tue, 18 Dec 2018 16:19:52 -0500 Subject: [PATCH 07/13] polish task order forms --- atst/domain/task_orders.py | 2 +- atst/forms/task_order.py | 33 +++--------- atst/routes/task_orders/new.py | 4 ++ templates/components/user_info.html | 23 ++++++++ templates/navigation/global_navigation.html | 6 +++ templates/task_orders/new/_user_fields.html | 19 +++++++ templates/task_orders/new/app_info.html | 14 ++++- templates/task_orders/new/funding.html | 54 ++++++++++++++++++- templates/task_orders/new/oversight.html | 24 +++------ templates/task_orders/new/review.html | 17 ++++++ .../routes/task_orders/test_new_task_order.py | 14 +++++ 11 files changed, 164 insertions(+), 46 deletions(-) create mode 100644 templates/components/user_info.html create mode 100644 templates/task_orders/new/_user_fields.html create mode 100644 templates/task_orders/new/review.html diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index e3b2c342..e5ef82f6 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -76,7 +76,7 @@ class TaskOrders(object): def is_section_complete(cls, task_order, section): if section in TaskOrders.SECTIONS: for attr in TaskOrders.SECTIONS[section]: - if not getattr(task_order, attr): + if getattr(task_order, attr) is None: return False return True diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 86a7efb8..03854c95 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -1,5 +1,4 @@ from wtforms.fields import ( - DateField, IntegerField, RadioField, SelectField, @@ -7,6 +6,7 @@ from wtforms.fields import ( StringField, TextAreaField, ) +from wtforms.fields.html5 import DateField from .forms import CacheableForm from .data import ( @@ -28,9 +28,7 @@ class AppInfoForm(CacheableForm): description="The name of your office or organization. You can add multiple applications to your portfolio. Your task orders are used to pay for these applications and their environments", ) defense_component = SelectField( - "Department of Defense Component", - description="Your team's plan for using the cloud, such as migrating an existing application or creating a prototype.", - choices=SERVICE_BRANCHES, + "Department of Defense Component", choices=SERVICE_BRANCHES ) app_migration = RadioField( "App Migration", @@ -66,27 +64,12 @@ class AppInfoForm(CacheableForm): class FundingForm(CacheableForm): - start_date = DateField( - "Period of Performance", - description="Select a start and end date for your Task Order to be active. Please note, this will likely be revised once your Task Order has been approved.", - ) - end_date = DateField("Period of Performance") - clin_01 = IntegerField( - "CLIN 01 : Unclassified Cloud Offerings", - description="UNCLASSIFIED Infrastructure as a Service (IaaS) and Platform as a Service (PaaS) offerings. ", - ) - clin_02 = IntegerField( - "CLIN 02: Classified Cloud Offerings", - description="CLASSIFIED Infrastructure as a Service (IaaS) and Platform as a Service (PaaS) offerings. ", - ) - clin_03 = IntegerField( - "CLIN 03: Unclassified Cloud Support and Assistance", - description="UNCLASSIFIED technical guidance from the cloud service provider, including architecture, configuration of IaaS and PaaS, integration, troubleshooting assistance, and other services.", - ) - clin_04 = IntegerField( - "CLIN 04: Classified Cloud Support and Assistance", - description="CLASSIFIED technical guidance from the cloud service provider, including architecture, configuration of IaaS and PaaS, integration, troubleshooting assistance, and other services.", - ) + start_date = DateField("Start Date", format="%m/%d/%Y") + end_date = DateField("End Date", format="%m/%d/%Y") + clin_01 = IntegerField("CLIN 01 : Unclassified") + clin_02 = IntegerField("CLIN 02: Classified") + clin_03 = IntegerField("CLIN 03: Unclassified") + clin_04 = IntegerField("CLIN 04: Classified") class OversightForm(CacheableForm): diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index 507f2471..059ebc1b 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -76,9 +76,13 @@ class ShowTaskOrderWorkflow: return screen_info +from flask import current_app as app + + class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): def __init__(self, form_data, user, screen=1, task_order_id=None): self.form_data = form_data + app.logger.info(form_data) self.user = user self.screen = screen self.task_order_id = task_order_id diff --git a/templates/components/user_info.html b/templates/components/user_info.html new file mode 100644 index 00000000..7c6378ca --- /dev/null +++ b/templates/components/user_info.html @@ -0,0 +1,23 @@ +{% from "components/text_input.html" import TextInput %} + +{% macro UserInfo(first_name, last_name, email, dod_id) -%} +
    +
    + {{ TextInput(first_name) }} +
    + +
    + {{ TextInput(last_name) }} +
    +
    + +
    +
    + {{ TextInput(email, placeholder='name@mail.mil') }} +
    + +
    + {{ TextInput(dod_id, placeholder='1234567890') }} +
    +
    +{% endmacro %} diff --git a/templates/navigation/global_navigation.html b/templates/navigation/global_navigation.html index 093b86bd..a87c329d 100644 --- a/templates/navigation/global_navigation.html +++ b/templates/navigation/global_navigation.html @@ -11,6 +11,12 @@ ] ) }} + {{ SidenavItem("New Task Order", + href=url_for("task_orders.new", screen=1), + icon="plus", + active=g.matchesPath('/task_orders/new'), + ) }} + {% if g.current_user.has_workspaces %} {{ SidenavItem("Workspaces", href="/workspaces", icon="cloud", active=g.matchesPath('/workspaces')) }} {% endif %} diff --git a/templates/task_orders/new/_user_fields.html b/templates/task_orders/new/_user_fields.html new file mode 100644 index 00000000..51028005 --- /dev/null +++ b/templates/task_orders/new/_user_fields.html @@ -0,0 +1,19 @@ +
    +
    + {{ TextInput(first_name) }} +
    + +
    + {{ TextInput(last_name) }} +
    +
    + +
    +
    + {{ TextInput(email, placeholder='name@mail.mil') }} +
    + +
    + {{ TextInput(dod_id, placeholder='1234567890') }} +
    +
    diff --git a/templates/task_orders/new/app_info.html b/templates/task_orders/new/app_info.html index 86acc4e7..09a088a7 100644 --- a/templates/task_orders/new/app_info.html +++ b/templates/task_orders/new/app_info.html @@ -13,8 +13,14 @@ {% include "fragments/flash.html" %}

    Basic Information

    -{{ TextInput(form.portfolio_name) }} +{{ TextInput(form.portfolio_name, placeholder="The name of your office or organization") }} {{ TextInput(form.scope, paragraph=True) }} +

    + + Not sure how to describe your scope? Read some Sample Scopes to + get an idea of what is appropriate. + +

    {{ OptionsInput(form.defense_component) }}
    @@ -35,7 +41,11 @@

    Market Research

    -

    View JEDI Market Research Memo

    +

    +The JEDI Cloud Computing Program Office (CCPO) has completed the market +research requirement for all related task orders. The Department of Defense CIO +has approved this research. View JEDI Cloud Market Research +

    {% endblock %} diff --git a/templates/task_orders/new/funding.html b/templates/task_orders/new/funding.html index a5189b4b..4cc4b57c 100644 --- a/templates/task_orders/new/funding.html +++ b/templates/task_orders/new/funding.html @@ -13,14 +13,64 @@ {% include "fragments/flash.html" %} +

    Period of Performance

    + +

    Choose the dates your task order will cover.

    + +

    +Because your funds will be lost if you don’t use them, we strongly recommend +submitting small, short-duration task orders, usually a three month period. +We’ll notify you when your period of performance is nearing the end so you can +request your next set of funds with a new task order. +

    + {{ DateInput(form.start_date, placeholder='MM / DD / YYYY', validation='date') }} {{ DateInput(form.end_date, placeholder='MM / DD / YYYY', validation='date') }} -

    Cloud Usage Estimate

    -

    Upload a copy of your CSP Cost Estimate Research

    +
    +

    Cloud Usage Estimate

    + +

    + Calculate how much your cloud usage will cost. A technical representative + should help you complete this calculation. + + Cloud Service Provider's estimate calculator + +

    +

    Upload a copy of your CSP Cost Estimate Research

    + +

    +Upload your anticipated cloud usage from the CSP tool linked above. PDFs and +screengrabs of the tool are sufficient. +

    +

    +This is only an estimation tool to help you make and informed evaluation of +what you expect to use. While you're tied to the dollar amount you specify in +your task order, you're not obligated by the resources you indicate in the +calculator. +

    + + +

    Cloud Usage Calculations

    +

    +Enter the results of your cloud usage calculations. These will correspond with +your task order's period of performance. +

    +

    Cloud Offerings

    +

    +Infrastructure as a Service (IaaS) and Platform as a Service (PaaS) offerings +

    + {{ TextInput(form.clin_01, validation='dollars') }} {{ TextInput(form.clin_02, validation='dollars') }} + +

    Cloud Support and Assistance

    +

    +Technical guidance from the cloud service provider, including architecture, +configuration of IaaS and PaaS, integration, troubleshooting assistance, and +other services. +

    {{ TextInput(form.clin_03, validation='dollars', tooltip='The cloud support and assistance packages cannot be used as a primary development resource.') }} {{ TextInput(form.clin_04, validation='dollars', tooltip='The cloud support and assistance packages cannot be used as a primary development resource.') }}

    Total Task Order Value

    diff --git a/templates/task_orders/new/oversight.html b/templates/task_orders/new/oversight.html index f48c3d2c..42c316e9 100644 --- a/templates/task_orders/new/oversight.html +++ b/templates/task_orders/new/oversight.html @@ -1,11 +1,9 @@ {% extends 'task_orders/_new.html' %} -{% from "components/text_input.html" import TextInput %} -{% from "components/options_input.html" import OptionsInput %} -{% from "components/date_input.html" import DateInput %} +{% from "components/user_info.html" import UserInfo %} {% block heading %} - Funding + Oversight {% endblock %} {% block form %} @@ -14,19 +12,13 @@

    Contracting Officer (KO) Information

    -{{ TextInput(form.ko_first_name) }} -{{ TextInput(form.ko_last_name) }} -{{ TextInput(form.ko_email) }} -{{ TextInput(form.ko_dod_id) }} + +{{ UserInfo(form.ko_first_name, form.ko_last_name, form.ko_email, form.ko_dod_id) }} +

    Contractive Officer Representative (COR) Information

    -{{ TextInput(form.cor_first_name) }} -{{ TextInput(form.cor_last_name) }} -{{ TextInput(form.cor_email) }} -{{ TextInput(form.cor_dod_id) }} +{{ UserInfo(form.cor_first_name, form.cor_last_name, form.cor_email, form.cor_dod_id) }} +

    Security Officer Information

    -{{ TextInput(form.so_first_name) }} -{{ TextInput(form.so_last_name) }} -{{ TextInput(form.so_email) }} -{{ TextInput(form.so_dod_id) }} +{{ UserInfo(form.so_first_name, form.so_last_name, form.so_email, form.so_dod_id) }} {% endblock %} diff --git a/templates/task_orders/new/review.html b/templates/task_orders/new/review.html new file mode 100644 index 00000000..6296fe91 --- /dev/null +++ b/templates/task_orders/new/review.html @@ -0,0 +1,17 @@ +{% extends 'task_orders/_new.html' %} + +{% from "components/text_input.html" import TextInput %} +{% from "components/options_input.html" import OptionsInput %} +{% from "components/date_input.html" import DateInput %} + +{% block heading %} + Review & Download +{% endblock %} + +{% block form %} + +{% include "fragments/flash.html" %} + +Download your Task Order Packet. + +{% endblock %} diff --git a/tests/routes/task_orders/test_new_task_order.py b/tests/routes/task_orders/test_new_task_order.py index 182fe4bb..2b9009d9 100644 --- a/tests/routes/task_orders/test_new_task_order.py +++ b/tests/routes/task_orders/test_new_task_order.py @@ -26,6 +26,19 @@ def slice_data_for_section(task_order_data, section): return {k: v for k, v in task_order_data.items() if k in attrs} +def serialize_dates(data): + if not data: + return data + + dates = { + k: v.strftime("%m/%d/%Y") for k, v in data.items() if hasattr(v, "strftime") + } + + data.update(dates) + + return data + + # TODO: this test will need to be more complicated when we add validation to # the forms def test_create_new_task_order(client, user_session): @@ -43,6 +56,7 @@ def test_create_new_task_order(client, user_session): assert url_for("task_orders.new", screen=2) in response.headers["Location"] funding_data = slice_data_for_section(task_order_data, "funding") + funding_data = serialize_dates(funding_data) response = client.post( response.headers["Location"], data=funding_data, follow_redirects=False ) From 5fe20fab8333133375297ad00c586dfb333e242a Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 19 Dec 2018 10:11:31 -0500 Subject: [PATCH 08/13] squash migrations and use numeric type for money --- .../6172ac7b8b26_new_task_order_table.py | 73 ++++++++++++++ .../a4cb6444eb4a_new_task_order_table.py | 46 --------- ...ac86_workspace_request_relationship_is_.py | 32 ------- .../ea06f5863083_update_task_order_columns.py | 96 ------------------- atst/models/task_order.py | 10 +- 5 files changed, 78 insertions(+), 179 deletions(-) create mode 100644 alembic/versions/6172ac7b8b26_new_task_order_table.py delete mode 100644 alembic/versions/a4cb6444eb4a_new_task_order_table.py delete mode 100644 alembic/versions/c457386dac86_workspace_request_relationship_is_.py delete mode 100644 alembic/versions/ea06f5863083_update_task_order_columns.py diff --git a/alembic/versions/6172ac7b8b26_new_task_order_table.py b/alembic/versions/6172ac7b8b26_new_task_order_table.py new file mode 100644 index 00000000..d3346f14 --- /dev/null +++ b/alembic/versions/6172ac7b8b26_new_task_order_table.py @@ -0,0 +1,73 @@ +"""new task order table + +Revision ID: 6172ac7b8b26 +Revises: 1c1394e496a7 +Create Date: 2018-12-19 10:00:51.373083 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '6172ac7b8b26' +down_revision = '1c1394e496a7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('task_orders', + 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('workspace_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('scope', sa.String(), nullable=True), + sa.Column('defense_component', sa.String(), nullable=True), + sa.Column('app_migration', sa.String(), nullable=True), + sa.Column('native_apps', sa.String(), nullable=True), + sa.Column('complexity', sa.ARRAY(sa.String()), nullable=True), + sa.Column('complexity_other', sa.String(), nullable=True), + sa.Column('dev_team', sa.ARRAY(sa.String()), nullable=True), + sa.Column('dev_team_other', sa.String(), nullable=True), + sa.Column('team_experience', sa.String(), nullable=True), + sa.Column('start_date', sa.Date(), nullable=True), + sa.Column('end_date', sa.Date(), nullable=True), + sa.Column('clin_01', sa.Numeric(scale=2), nullable=True), + sa.Column('clin_02', sa.Numeric(scale=2), nullable=True), + sa.Column('clin_03', sa.Numeric(scale=2), nullable=True), + sa.Column('clin_04', sa.Numeric(scale=2), nullable=True), + sa.Column('ko_first_name', sa.String(), nullable=True), + sa.Column('ko_last_name', sa.String(), nullable=True), + sa.Column('ko_email', sa.String(), nullable=True), + sa.Column('ko_dod_id', sa.String(), nullable=True), + sa.Column('cor_first_name', sa.String(), nullable=True), + sa.Column('cor_last_name', sa.String(), nullable=True), + sa.Column('cor_email', sa.String(), nullable=True), + sa.Column('cor_dod_id', sa.String(), nullable=True), + sa.Column('so_first_name', sa.String(), nullable=True), + sa.Column('so_last_name', sa.String(), nullable=True), + sa.Column('so_email', sa.String(), nullable=True), + sa.Column('so_dod_id', sa.String(), nullable=True), + sa.Column('number', sa.String(), nullable=True), + sa.Column('loa', sa.ARRAY(sa.String()), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('number') + ) + op.alter_column('workspaces', 'request_id', + existing_type=postgresql.UUID(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('workspaces', 'request_id', + existing_type=postgresql.UUID(), + nullable=False) + op.drop_table('task_orders') + # ### end Alembic commands ### diff --git a/alembic/versions/a4cb6444eb4a_new_task_order_table.py b/alembic/versions/a4cb6444eb4a_new_task_order_table.py deleted file mode 100644 index ce0583bd..00000000 --- a/alembic/versions/a4cb6444eb4a_new_task_order_table.py +++ /dev/null @@ -1,46 +0,0 @@ -"""new task order table - -Revision ID: a4cb6444eb4a -Revises: c457386dac86 -Create Date: 2018-12-13 09:17:25.406453 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = 'a4cb6444eb4a' -down_revision = 'c457386dac86' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('task_orders', - 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('number', sa.String(), nullable=True), - sa.Column('clin_0001', sa.Integer(), nullable=True), - sa.Column('clin_0003', sa.Integer(), nullable=True), - sa.Column('clin_1001', sa.Integer(), nullable=True), - sa.Column('clin_1003', sa.Integer(), nullable=True), - sa.Column('clin_2001', sa.Integer(), nullable=True), - sa.Column('clin_2003', sa.Integer(), nullable=True), - sa.Column('expiration_date', sa.Date(), nullable=True), - sa.Column('workspace_id', postgresql.UUID(as_uuid=True), nullable=True), - sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('number') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('task_orders') - # ### end Alembic commands ### diff --git a/alembic/versions/c457386dac86_workspace_request_relationship_is_.py b/alembic/versions/c457386dac86_workspace_request_relationship_is_.py deleted file mode 100644 index 3c8f9fc7..00000000 --- a/alembic/versions/c457386dac86_workspace_request_relationship_is_.py +++ /dev/null @@ -1,32 +0,0 @@ -"""workspace request relationship is nullable - -Revision ID: c457386dac86 -Revises: 1c1394e496a7 -Create Date: 2018-12-13 08:57:09.319288 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = 'c457386dac86' -down_revision = '1c1394e496a7' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('workspaces', 'request_id', - existing_type=postgresql.UUID(), - nullable=True) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('workspaces', 'request_id', - existing_type=postgresql.UUID(), - nullable=False) - # ### end Alembic commands ### diff --git a/alembic/versions/ea06f5863083_update_task_order_columns.py b/alembic/versions/ea06f5863083_update_task_order_columns.py deleted file mode 100644 index 03b6d744..00000000 --- a/alembic/versions/ea06f5863083_update_task_order_columns.py +++ /dev/null @@ -1,96 +0,0 @@ -"""update task order columns - -Revision ID: ea06f5863083 -Revises: a4cb6444eb4a -Create Date: 2018-12-14 13:19:11.956511 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'ea06f5863083' -down_revision = 'a4cb6444eb4a' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('task_orders', sa.Column('app_migration', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('clin_01', sa.Integer(), nullable=True)) - op.add_column('task_orders', sa.Column('clin_02', sa.Integer(), nullable=True)) - op.add_column('task_orders', sa.Column('clin_03', sa.Integer(), nullable=True)) - op.add_column('task_orders', sa.Column('clin_04', sa.Integer(), nullable=True)) - op.add_column('task_orders', sa.Column('complexity', sa.ARRAY(sa.String()), nullable=True)) - op.add_column('task_orders', sa.Column('complexity_other', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('cor_dod_id', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('cor_email', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('cor_first_name', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('cor_last_name', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('defense_component', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('dev_team', sa.ARRAY(sa.String()), nullable=True)) - op.add_column('task_orders', sa.Column('dev_team_other', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('end_date', sa.Date(), nullable=True)) - op.add_column('task_orders', sa.Column('ko_dod_id', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('ko_email', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('ko_first_name', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('ko_last_name', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('loa', sa.ARRAY(sa.String()), nullable=True)) - op.add_column('task_orders', sa.Column('native_apps', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('scope', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('so_dod_id', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('so_email', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('so_first_name', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('so_last_name', sa.String(), nullable=True)) - op.add_column('task_orders', sa.Column('start_date', sa.Date(), nullable=True)) - op.add_column('task_orders', sa.Column('team_experience', sa.String(), nullable=True)) - op.drop_column('task_orders', 'expiration_date') - op.drop_column('task_orders', 'clin_1003') - op.drop_column('task_orders', 'clin_0001') - op.drop_column('task_orders', 'clin_2003') - op.drop_column('task_orders', 'clin_1001') - op.drop_column('task_orders', 'clin_2001') - op.drop_column('task_orders', 'clin_0003') - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('task_orders', sa.Column('clin_0003', sa.INTEGER(), autoincrement=False, nullable=True)) - op.add_column('task_orders', sa.Column('clin_2001', sa.INTEGER(), autoincrement=False, nullable=True)) - op.add_column('task_orders', sa.Column('clin_1001', sa.INTEGER(), autoincrement=False, nullable=True)) - op.add_column('task_orders', sa.Column('clin_2003', sa.INTEGER(), autoincrement=False, nullable=True)) - op.add_column('task_orders', sa.Column('clin_0001', sa.INTEGER(), autoincrement=False, nullable=True)) - op.add_column('task_orders', sa.Column('clin_1003', sa.INTEGER(), autoincrement=False, nullable=True)) - op.add_column('task_orders', sa.Column('expiration_date', sa.DATE(), autoincrement=False, nullable=True)) - op.drop_column('task_orders', 'team_experience') - op.drop_column('task_orders', 'start_date') - op.drop_column('task_orders', 'so_last_name') - op.drop_column('task_orders', 'so_first_name') - op.drop_column('task_orders', 'so_email') - op.drop_column('task_orders', 'so_dod_id') - op.drop_column('task_orders', 'scope') - op.drop_column('task_orders', 'native_apps') - op.drop_column('task_orders', 'loa') - op.drop_column('task_orders', 'ko_last_name') - op.drop_column('task_orders', 'ko_first_name') - op.drop_column('task_orders', 'ko_email') - op.drop_column('task_orders', 'ko_dod_id') - op.drop_column('task_orders', 'end_date') - op.drop_column('task_orders', 'dev_team_other') - op.drop_column('task_orders', 'dev_team') - op.drop_column('task_orders', 'defense_component') - op.drop_column('task_orders', 'cor_last_name') - op.drop_column('task_orders', 'cor_first_name') - op.drop_column('task_orders', 'cor_email') - op.drop_column('task_orders', 'cor_dod_id') - op.drop_column('task_orders', 'complexity_other') - op.drop_column('task_orders', 'complexity') - op.drop_column('task_orders', 'clin_04') - op.drop_column('task_orders', 'clin_03') - op.drop_column('task_orders', 'clin_02') - op.drop_column('task_orders', 'clin_01') - op.drop_column('task_orders', 'app_migration') - # ### end Alembic commands ### diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 77f631d3..53afe102 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, ForeignKey, Date +from sqlalchemy import Column, Numeric, String, ForeignKey, Date from sqlalchemy.types import ARRAY from sqlalchemy.orm import relationship @@ -27,10 +27,10 @@ class TaskOrder(Base, mixins.TimestampsMixin): team_experience = Column(String) # Team Experience start_date = Column(Date) # Period of Performance end_date = Column(Date) - clin_01 = Column(Integer) # CLIN 01 : Unclassified Cloud Offerings - clin_02 = Column(Integer) # CLIN 02: Classified Cloud Offerings - clin_03 = Column(Integer) # CLIN 03: Unclassified Cloud Support and Assistance - clin_04 = Column(Integer) # CLIN 04: Classified Cloud Support and Assistance + clin_01 = Column(Numeric(scale=2)) + clin_02 = Column(Numeric(scale=2)) + clin_03 = Column(Numeric(scale=2)) + clin_04 = Column(Numeric(scale=2)) ko_first_name = Column(String) # First Name ko_last_name = Column(String) # Last Name ko_email = Column(String) # Email From 49f036ab27a1a8ff4350a5f2354fa5c0683eb107 Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 19 Dec 2018 10:17:02 -0500 Subject: [PATCH 09/13] remove leftover logger --- atst/routes/task_orders/new.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index 059ebc1b..507f2471 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -76,13 +76,9 @@ class ShowTaskOrderWorkflow: return screen_info -from flask import current_app as app - - class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): def __init__(self, form_data, user, screen=1, task_order_id=None): self.form_data = form_data - app.logger.info(form_data) self.user = user self.screen = screen self.task_order_id = task_order_id From 62a8b0ec40a170b375292636337f8834faf6289b Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 19 Dec 2018 11:33:58 -0500 Subject: [PATCH 10/13] add tests for task_order routes utility classes --- atst/domain/task_orders.py | 2 - tests/factories.py | 18 ++++--- .../routes/task_orders/test_new_task_order.py | 52 +++++++++++++++++++ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index e5ef82f6..a7b800d5 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -13,9 +13,7 @@ class TaskOrders(object): "app_migration", "native_apps", "complexity", - "complexity_other", "dev_team", - "dev_team_other", "team_experience", ], "funding": [ diff --git a/tests/factories.py b/tests/factories.py index e6106b83..d2b9e82a 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -367,10 +367,12 @@ class TaskOrderFactory(Base): class Meta: model = TaskOrder - clin_01 = random.randrange(100, 100_000) - clin_03 = random.randrange(100, 100_000) - clin_02 = random.randrange(100, 100_000) - clin_04 = random.randrange(100, 100_000) + workspace = factory.SubFactory(WorkspaceFactory) + + clin_01 = factory.LazyFunction(lambda *args: random.randrange(100, 100_000)) + clin_03 = factory.LazyFunction(lambda *args: random.randrange(100, 100_000)) + clin_02 = factory.LazyFunction(lambda *args: random.randrange(100, 100_000)) + clin_04 = factory.LazyFunction(lambda *args: random.randrange(100, 100_000)) defense_component = factory.LazyFunction(random_service_branch) app_migration = random_choice(data.APP_MIGRATION) @@ -380,8 +382,12 @@ class TaskOrderFactory(Base): team_experience = random_choice(data.TEAM_EXPERIENCE) scope = factory.Faker("sentence") - start_date = random_future_date(year_min=1, year_max=1) - end_date = random_future_date(year_min=2, year_max=5) + start_date = factory.LazyFunction( + lambda *args: random_future_date(year_min=1, year_max=1) + ) + end_date = factory.LazyFunction( + lambda *args: random_future_date(year_min=2, year_max=5) + ) ko_first_name = factory.Faker("first_name") ko_last_name = factory.Faker("last_name") diff --git a/tests/routes/task_orders/test_new_task_order.py b/tests/routes/task_orders/test_new_task_order.py index 2b9009d9..ea023e38 100644 --- a/tests/routes/task_orders/test_new_task_order.py +++ b/tests/routes/task_orders/test_new_task_order.py @@ -2,6 +2,7 @@ import pytest from flask import url_for from atst.domain.task_orders import TaskOrders +from atst.routes.task_orders.new import ShowTaskOrderWorkflow, UpdateTaskOrderWorkflow from tests.factories import UserFactory, TaskOrderFactory @@ -67,3 +68,54 @@ def test_create_new_task_order(client, user_session): response.headers["Location"], data=oversight_data, follow_redirects=False ) assert url_for("task_orders.new", screen=4) in response.headers["Location"] + + +def test_show_task_order(): + workflow = ShowTaskOrderWorkflow() + assert workflow.task_order is None + task_order = TaskOrderFactory.create() + another_workflow = ShowTaskOrderWorkflow(task_order_id=task_order.id) + assert another_workflow.task_order == task_order + + +def test_show_task_order_form(): + workflow = ShowTaskOrderWorkflow() + assert not workflow.form.data["app_migration"] + task_order = TaskOrderFactory.create() + another_workflow = ShowTaskOrderWorkflow(task_order_id=task_order.id) + assert ( + another_workflow.form.data["defense_component"] == task_order.defense_component + ) + + +def test_show_task_order_display_screen(): + task_order = TaskOrderFactory.create() + workflow = ShowTaskOrderWorkflow(task_order_id=task_order.id) + screens = workflow.display_screens + # every form section is complete + for i in range(2): + assert screens[i]["complete"] + # the review section is not + assert not screens[3].get("complete") + + +def test_update_task_order_with_no_task_order(): + user = UserFactory.create() + to_data = TaskOrderFactory.dictionary() + workflow = UpdateTaskOrderWorkflow(to_data, user) + assert workflow.task_order is None + workflow.update() + assert workflow.task_order + assert workflow.task_order.scope == to_data["scope"] + + +def test_update_task_order_with_existing_task_order(): + user = UserFactory.create() + task_order = TaskOrderFactory.create() + to_data = serialize_dates(TaskOrderFactory.dictionary()) + workflow = UpdateTaskOrderWorkflow( + to_data, user, screen=2, task_order_id=task_order.id + ) + assert workflow.task_order.start_date != to_data["start_date"] + workflow.update() + assert workflow.task_order.start_date.strftime("%m/%d/%Y") == to_data["start_date"] From c4a7b59f7fd1460ad8b9cc6fe95ff9e8e8bd35ff Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 19 Dec 2018 11:41:33 -0500 Subject: [PATCH 11/13] fix scope description and task order active step display --- atst/forms/task_order.py | 2 +- templates/task_orders/new/menu.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 03854c95..903149fe 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -25,7 +25,7 @@ class AppInfoForm(CacheableForm): ) scope = TextAreaField( "Cloud Project Scope", - description="The name of your office or organization. You can add multiple applications to your portfolio. Your task orders are used to pay for these applications and their environments", + description="Your team's plan for using the cloud, such as migrating an existing application or creating a prototype.", ) defense_component = SelectField( "Department of Defense Component", choices=SERVICE_BRANCHES diff --git a/templates/task_orders/new/menu.html b/templates/task_orders/new/menu.html index 19550e13..f7b088ad 100644 --- a/templates/task_orders/new/menu.html +++ b/templates/task_orders/new/menu.html @@ -11,7 +11,7 @@
  • {{ s['title'] }} From c99b02b9755947e1cf86a06778dec07c06006e56 Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 19 Dec 2018 13:32:02 -0500 Subject: [PATCH 12/13] adjust migration downgrade --- alembic/versions/6172ac7b8b26_new_task_order_table.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/alembic/versions/6172ac7b8b26_new_task_order_table.py b/alembic/versions/6172ac7b8b26_new_task_order_table.py index d3346f14..8f91da11 100644 --- a/alembic/versions/6172ac7b8b26_new_task_order_table.py +++ b/alembic/versions/6172ac7b8b26_new_task_order_table.py @@ -66,6 +66,11 @@ def upgrade(): def downgrade(): # ### commands auto generated by Alembic - please adjust! ### + conn = op.get_bind() + conn.execute("DELETE FROM workspace_roles wr USING workspaces w WHERE w.id=wr.workspace_id AND w.request_id IS NULL") + conn.execute("DELETE FROM audit_events ae USING workspaces w WHERE w.id=ae.workspace_id AND w.request_id IS NULL") + conn.execute("DELETE FROM task_orders tasks USING workspaces w WHERE w.id=tasks.workspace_id AND w.request_id IS NULL") + conn.execute("DELETE FROM workspaces WHERE request_id IS NULL") op.alter_column('workspaces', 'request_id', existing_type=postgresql.UUID(), nullable=False) From 27ad286e47f23f14043c17f60bf5936a63e24492 Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 19 Dec 2018 14:43:56 -0500 Subject: [PATCH 13/13] remove unused templates --- templates/task_orders/edit.html | 45 --------------------------------- templates/workspaces/new.html | 36 -------------------------- 2 files changed, 81 deletions(-) delete mode 100644 templates/task_orders/edit.html delete mode 100644 templates/workspaces/new.html diff --git a/templates/task_orders/edit.html b/templates/task_orders/edit.html deleted file mode 100644 index a0e3c4b2..00000000 --- a/templates/task_orders/edit.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "base.html" %} - -{% from "components/text_input.html" import TextInput %} -{% from "components/options_input.html" import OptionsInput %} -{% from "components/date_input.html" import DateInput %} - -{% block content %} - -{% include "fragments/flash.html" %} - -
    - {{ form.csrf_token }} - -
    - -
    -

    Task Order

    -
    - -
    -

    DoD Contract Security Classification Specification

    - - - -
    - -
    - -

    Download your Task Order Packet.

    - - - - - {{ TextInput(form.number) }} - {{ TextInput(form.number_confirm) }} - {{ TextInput(form.loa) }} -

    Add another LOA

    - -

    I certify that the task order information above is accurate and that funding has been allocated to the above task order.

    -
    -
    - -
    - -{% endblock %} diff --git a/templates/workspaces/new.html b/templates/workspaces/new.html deleted file mode 100644 index eea8a657..00000000 --- a/templates/workspaces/new.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "workspaces/base.html" %} - -{% from "components/icon.html" import Icon %} -{% from "components/text_input.html" import TextInput %} - -{% block workspace_content %} - -{% include "fragments/flash.html" %} - -
    - {{ form.csrf_token }} - -
    - -
    -

    Workspace Settings

    -
    - -
    - {{ TextInput(form.name, validation="workspaceName") }} -
    -
    - - - - - -
    - -{% endblock %}