diff --git a/alembic/versions/71cbe76c3b87_add_officers_to_task_order.py b/alembic/versions/71cbe76c3b87_add_officers_to_task_order.py new file mode 100644 index 00000000..a90ca5ba --- /dev/null +++ b/alembic/versions/71cbe76c3b87_add_officers_to_task_order.py @@ -0,0 +1,38 @@ +"""add officers to task order + +Revision ID: 71cbe76c3b87 +Revises: 6172ac7b8b26 +Create Date: 2019-01-04 10:16:50.062349 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '71cbe76c3b87' +down_revision = '6172ac7b8b26' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_orders', sa.Column('cor_id', postgresql.UUID(as_uuid=True), nullable=True)) + op.add_column('task_orders', sa.Column('ko_id', postgresql.UUID(as_uuid=True), nullable=True)) + op.add_column('task_orders', sa.Column('so_id', postgresql.UUID(as_uuid=True), nullable=True)) + op.create_foreign_key(None, 'task_orders', 'users', ['so_id'], ['id']) + op.create_foreign_key(None, 'task_orders', 'users', ['cor_id'], ['id']) + op.create_foreign_key(None, 'task_orders', 'users', ['ko_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'task_orders', type_='foreignkey') + op.drop_constraint(None, 'task_orders', type_='foreignkey') + op.drop_constraint(None, 'task_orders', type_='foreignkey') + op.drop_column('task_orders', 'so_id') + op.drop_column('task_orders', 'ko_id') + op.drop_column('task_orders', 'cor_id') + # ### end Alembic commands ### diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index a7b800d5..1fa2ac4d 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -4,6 +4,9 @@ from atst.database import db from atst.models.task_order import TaskOrder from .exceptions import NotFoundError +class TaskOrderError(Exception): + pass + class TaskOrders(object): SECTIONS = { @@ -89,3 +92,17 @@ class TaskOrders(object): return False return True + + OFFICERS = ["contracting_officer", "contracting_officer_representative", "security_officer"] + + @classmethod + def add_officer(cls, task_order, user, role): + if role in TaskOrders.OFFICERS: + setattr(task_order, role, user) + + db.session.add(task_order) + db.session.commit() + + return task_order + else: + raise TaskOrderError("{} is not an officer role on task orders".format(role)) diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 29b220d2..27b6cc06 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -14,7 +14,18 @@ class TaskOrder(Base, mixins.TimestampsMixin): workspace = relationship("Workspace") user_id = Column(ForeignKey("users.id")) - creator = relationship("User") + creator = relationship("User", foreign_keys="TaskOrder.user_id") + + ko_id = Column(ForeignKey("users.id")) + contracting_officer = relationship("User", foreign_keys="TaskOrder.ko_id") + + cor_id = Column(ForeignKey("users.id")) + contracting_officer_representative = relationship( + "User", foreign_keys="TaskOrder.cor_id" + ) + + so_id = Column(ForeignKey("users.id")) + security_officer = relationship("User", foreign_keys="TaskOrder.so_id") scope = Column(String) # Cloud Project Scope defense_component = Column(String) # Department of Defense Component diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index ea996b29..ea01786f 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -154,7 +154,7 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): }, ] - def _update_invitations(self): + def _update_officer_invitations(self): for officer_type in self.OFFICER_INVITATIONS: field = officer_type["field"] if ( @@ -169,16 +169,19 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): } invite_service = InvitationService( self.user, - self.task_order.workspace, + self.workspace, {**officer_data, "workspace_role": officer_type["role"]}, subject=officer_type["subject"], email_template=officer_type["template"], ) - invite_service.invite() + invite = invite_service.invite() + TaskOrders.add_officer( + self.task_order, invite.user, officer_type["role"] + ) def update(self): self._update_task_order() - self._update_invitations() + self._update_officer_invitations() return self.task_order diff --git a/atst/routes/workspaces/invitations.py b/atst/routes/workspaces/invitations.py index 129c87bc..af1d423e 100644 --- a/atst/routes/workspaces/invitations.py +++ b/atst/routes/workspaces/invitations.py @@ -20,6 +20,25 @@ def send_invite_email(owner_name, token, new_member_email): def accept_invitation(token): invite = Invitations.accept(g.current_user, token) + # TODO: this will eventually redirect to different places depending on + # whether the user is an officer for the TO and what kind of officer they + # are. It will also have to manage cases like: + # - the logged-in user has multiple roles on the TO (e.g., KO and COR) + # - the logged-in user has officer roles on multiple unsigned TOs + for task_order in invite.workspace.task_orders: + if g.current_user == task_order.contracting_officer: + return redirect( + url_for("task_orders.new", screen=4, task_order_id=task_order.id) + ) + elif g.current_user == task_order.contracting_officer_representative: + return redirect( + url_for("task_orders.new", screen=4, task_order_id=task_order.id) + ) + elif g.current_user == task_order.security_officer: + return redirect( + url_for("task_orders.new", screen=4, task_order_id=task_order.id) + ) + return redirect( url_for("workspaces.show_workspace", workspace_id=invite.workspace.id) ) diff --git a/tests/routes/task_orders/test_new_task_order.py b/tests/routes/task_orders/test_new_task_order.py index a6404853..eded7da0 100644 --- a/tests/routes/task_orders/test_new_task_order.py +++ b/tests/routes/task_orders/test_new_task_order.py @@ -146,12 +146,19 @@ def test_invite_officers_to_task_order(queue): ) workflow.update() workspace = task_order.workspace + # owner and three officers are workspace members assert len(workspace.members) == 4 roles = [member.role.name for member in workspace.members] + # officers exist in roles assert "contracting_officer" in roles assert "contracting_officer_representative" in roles assert "security_officer" in roles + # email invitations are enqueued assert len(queue.get_queue()) == 3 + # task order has relationship to user for each officer role + assert task_order.contracting_officer.dod_id == to_data["ko_dod_id"] + assert task_order.contracting_officer_representative.dod_id == to_data["cor_dod_id"] + assert task_order.security_officer.dod_id == to_data["so_dod_id"] def test_update_does_not_resend_invitation(): diff --git a/tests/routes/workspaces/test_invitations.py b/tests/routes/workspaces/test_invitations.py index d6e99ee0..1fcc4145 100644 --- a/tests/routes/workspaces/test_invitations.py +++ b/tests/routes/workspaces/test_invitations.py @@ -6,6 +6,7 @@ from tests.factories import ( WorkspaceFactory, WorkspaceRoleFactory, InvitationFactory, + TaskOrderFactory, ) from atst.domain.workspaces import Workspaces from atst.models.workspace_role import Status as WorkspaceRoleStatus @@ -207,3 +208,39 @@ def test_existing_member_invite_resent_to_email_submitted_in_form( assert user.email != "example@example.com" assert send_mail_job.func.__func__.__name__ == "_send_mail" assert send_mail_job.args[0] == ["example@example.com"] + + +def test_task_order_officer_accepts_invite(monkeypatch, client, user_session): + workspace = WorkspaceFactory.create() + task_order = TaskOrderFactory.create(workspace=workspace) + user_info = UserFactory.dictionary() + + # create contracting officer + user_session(workspace.owner) + client.post( + url_for("task_orders.new", screen=3, task_order_id=task_order.id), + data={ + "workspace_role": "contracting_officer", + "ko_first_name": user_info["first_name"], + "ko_last_name": user_info["last_name"], + "ko_email": user_info["email"], + "ko_dod_id": user_info["dod_id"], + "ko_invite": True, + }, + ) + + # contracting officer accepts invitation + user = Users.get_by_dod_id(user_info["dod_id"]) + token = user.invitations[0].token + monkeypatch.setattr( + "atst.domain.auth.should_redirect_to_user_profile", lambda *args: False + ) + user_session(user) + response = client.get(url_for("workspaces.accept_invitation", token=token)) + + # user is redirected to the task order review page + assert response.status_code == 302 + to_review_url = url_for( + "task_orders.new", screen=4, task_order_id=task_order.id, _external=True + ) + assert response.headers["Location"] == to_review_url