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..187f2649 --- /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: 91bd9482ce23 +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 = '91bd9482ce23' +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('task_orders_users_so_id', 'task_orders', 'users', ['so_id'], ['id']) + op.create_foreign_key('task_orders_users_cor_id', 'task_orders', 'users', ['cor_id'], ['id']) + op.create_foreign_key('task_orders_users_ko_id', 'task_orders', 'users', ['ko_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('task_orders_users_so_id', 'task_orders', type_='foreignkey') + op.drop_constraint('task_orders_users_cor_id', 'task_orders', type_='foreignkey') + op.drop_constraint('task_orders_users_ko_id', '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/roles.py b/atst/domain/roles.py index 25c08e8a..91137cbc 100644 --- a/atst/domain/roles.py +++ b/atst/domain/roles.py @@ -140,6 +140,12 @@ WORKSPACE_ROLES = [ Permissions.VIEW_WORKSPACE, ], }, + { + "name": "officer", + "description": "Officer involved with setting up a Task Order", + "display_name": "Task Order Officer", + "permissions": [], + }, ] diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index a7b800d5..9acd1107 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -2,9 +2,14 @@ from sqlalchemy.orm.exc import NoResultFound from atst.database import db from atst.models.task_order import TaskOrder +from atst.domain.workspaces import Workspaces from .exceptions import NotFoundError +class TaskOrderError(Exception): + pass + + class TaskOrders(object): SECTIONS = { "app_info": [ @@ -89,3 +94,42 @@ class TaskOrders(object): return False return True + + OFFICERS = [ + "contracting_officer", + "contracting_officer_representative", + "security_officer", + ] + + @classmethod + def add_officer(cls, user, task_order, officer_type, officer_data): + if officer_type in TaskOrders.OFFICERS: + workspace = task_order.workspace + + existing_member = next( + ( + member + for member in workspace.members + if member.user.dod_id == officer_data["dod_id"] + ), + None, + ) + + if existing_member: + workspace_user = existing_member.user + else: + member = Workspaces.create_member( + user, workspace, {**officer_data, "workspace_role": "officer"} + ) + workspace_user = member.user + + setattr(task_order, officer_type, workspace_user) + + db.session.add(task_order) + db.session.commit() + + return workspace_user + else: + raise TaskOrderError( + "{} is not an officer role on task orders".format(officer_type) + ) diff --git a/atst/forms/data.py b/atst/forms/data.py index b02cfb64..957dd911 100644 --- a/atst/forms/data.py +++ b/atst/forms/data.py @@ -108,6 +108,7 @@ COMPLETION_DATE_RANGES = [ WORKSPACE_ROLES = [ (role["name"], {"name": role["display_name"], "description": role["description"]}) for role in WORKSPACE_ROLE_DEFINITIONS + if role["name"] is not "officer" ] ENVIRONMENT_ROLES = [ diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 28063a5c..fb10774e 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -1,4 +1,5 @@ from wtforms.fields import ( + BooleanField, IntegerField, RadioField, SelectField, @@ -8,6 +9,9 @@ from wtforms.fields import ( ) from wtforms.fields.html5 import DateField from wtforms.widgets import ListWidget, CheckboxInput +from wtforms.validators import Required, Length + +from atst.forms.validators import IsNumber from .forms import CacheableForm from .data import ( @@ -86,15 +90,50 @@ class OversightForm(CacheableForm): ko_first_name = StringField("First Name") ko_last_name = StringField("Last Name") ko_email = StringField("Email") - ko_dod_id = StringField("DOD ID") + ko_dod_id = StringField( + "DOD ID", validators=[Required(), Length(min=10), IsNumber()] + ) + cor_first_name = StringField("First Name") cor_last_name = StringField("Last Name") cor_email = StringField("Email") - cor_dod_id = StringField("DOD ID") + cor_dod_id = StringField( + "DOD ID", validators=[Required(), Length(min=10), IsNumber()] + ) + so_first_name = StringField("First Name") so_last_name = StringField("Last Name") so_email = StringField("Email") - so_dod_id = StringField("DOD ID") + so_dod_id = StringField( + "DOD ID", validators=[Required(), Length(min=10), IsNumber()] + ) + + ko_invite = BooleanField( + "Invite KO to Task Order Builder", + description=""" + Your KO will need to approve funding for this Task Order by logging + into the JEDI Cloud Portal, submitting the Task Order documents + within their official system of record, and electronically signing. + You may choose to skip this for now and invite them later. + """, + ) + cor_invite = BooleanField( + "Invite COR to Task Order Builder", + description=""" + Your COR may assist with submitting the Task Order documents within + their official system of record. You may choose to skip this for + now and invite them later. + """, + ) + so_invite = BooleanField( + "Invite Security Officer to Task Order Builder", + description=""" + Your Security Officer will need to answer some security + configuration questions in order to generate a DD-254 document, + then electronically sign. You may choose to skip this for now + and invite them later. + """, + ) class ReviewForm(CacheableForm): 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 c4d68e5a..9534fe12 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -10,7 +10,9 @@ from flask import ( from . import task_orders_bp from atst.domain.task_orders import TaskOrders from atst.domain.workspaces import Workspaces +from atst.domain.workspace_roles import WorkspaceRoles import atst.forms.task_order as task_order_form +from atst.services.invitation import Invitation as InvitationService TASK_ORDER_SECTIONS = [ @@ -111,10 +113,15 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): def form(self): return self._section["form"](self.form_data) + @property + def workspace(self): + if self.task_order: + return self.task_order.workspace + def validate(self): return self.form.validate() - def update(self): + def _update_task_order(self): if self.task_order: TaskOrders.update(self.task_order, **self.form.data) else: @@ -124,6 +131,59 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): self._task_order = TaskOrders.create(workspace=ws, creator=self.user) TaskOrders.update(self.task_order, **to_data) + OFFICER_INVITATIONS = [ + { + "field": "ko_invite", + "prefix": "ko", + "role": "contracting_officer", + "subject": "Review a task order", + "template": "emails/invitation.txt", + }, + { + "field": "cor_invite", + "prefix": "cor", + "role": "contracting_officer_representative", + "subject": "Help with a task order", + "template": "emails/invitation.txt", + }, + { + "field": "so_invite", + "prefix": "so", + "role": "security_officer", + "subject": "Review security for a task order", + "template": "emails/invitation.txt", + }, + ] + + def _update_officer_invitations(self): + for officer_type in self.OFFICER_INVITATIONS: + field = officer_type["field"] + if ( + hasattr(self.form, field) + and self.form[field].data + and not getattr(self.task_order, officer_type["role"]) + ): + prefix = officer_type["prefix"] + officer_data = { + field: getattr(self.task_order, prefix + "_" + field) + for field in ["first_name", "last_name", "email", "dod_id"] + } + officer = TaskOrders.add_officer( + self.user, self.task_order, officer_type["role"], officer_data + ) + ws_officer_member = WorkspaceRoles.get(self.workspace.id, officer.id) + invite_service = InvitationService( + self.user, + ws_officer_member, + officer_data["email"], + subject=officer_type["subject"], + email_template=officer_type["template"], + ) + invite_service.invite() + + def update(self): + self._update_task_order() + 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/atst/routes/workspaces/members.py b/atst/routes/workspaces/members.py index f3f06d4e..7c590153 100644 --- a/atst/routes/workspaces/members.py +++ b/atst/routes/workspaces/members.py @@ -3,13 +3,13 @@ import re from flask import render_template, request as http_request, g, redirect, url_for from . import workspaces_bp -from atst.routes.workspaces.invitations import send_invite_email from atst.domain.exceptions import AlreadyExistsError from atst.domain.projects import Projects from atst.domain.workspaces import Workspaces from atst.domain.workspace_roles import WorkspaceRoles, MEMBER_STATUS_CHOICES from atst.domain.environments import Environments from atst.domain.environment_roles import EnvironmentRoles +from atst.services.invitation import Invitation as InvitationService from atst.forms.new_member import NewMemberForm from atst.forms.edit_member import EditMemberForm from atst.forms.data import ( @@ -19,7 +19,6 @@ from atst.forms.data import ( ) from atst.domain.authz import Authorization from atst.models.permissions import Permissions -from atst.domain.invitations import Invitations from atst.utils.flash import formatted_flash as flash @@ -68,13 +67,14 @@ def new_member(workspace_id): def create_member(workspace_id): workspace = Workspaces.get(g.current_user, workspace_id) form = NewMemberForm(http_request.form) - user = g.current_user if form.validate(): try: - new_member = Workspaces.create_member(user, workspace, form.data) - invite = Invitations.create(user, new_member, form.data["email"]) - send_invite_email(g.current_user.full_name, invite.token, invite.email) + member = Workspaces.create_member(g.current_user, workspace, form.data) + invite_service = InvitationService( + g.current_user, member, form.data.get("email") + ) + invite_service.invite() flash("new_workspace_member", new_member=new_member, workspace=workspace) diff --git a/atst/services/invitation.py b/atst/services/invitation.py new file mode 100644 index 00000000..f1badf2b --- /dev/null +++ b/atst/services/invitation.py @@ -0,0 +1,35 @@ +from flask import render_template + +from atst.domain.invitations import Invitations +from atst.queue import queue + + +class Invitation: + def __init__( + self, + inviter, + member, + email, + subject="{} has invited you to a JEDI Cloud Workspace", + email_template="emails/invitation.txt", + ): + self.inviter = inviter + self.member = member + self.email = email + self.subject = subject + self.email_template = email_template + + def invite(self): + invite = self._create_invite() + self._send_invite_email(invite.token) + + return invite + + def _create_invite(self): + return Invitations.create(self.inviter, self.member, self.email) + + def _send_invite_email(self, token): + body = render_template( + self.email_template, owner=self.inviter.full_name, token=token + ) + queue.send_mail([self.email], self.subject.format(self.inviter.full_name), body) diff --git a/templates/components/user_info.html b/templates/components/user_info.html index 7c6378ca..244c7e0d 100644 --- a/templates/components/user_info.html +++ b/templates/components/user_info.html @@ -17,7 +17,7 @@
- {{ TextInput(dod_id, placeholder='1234567890') }} + {{ TextInput(dod_id, placeholder='1234567890', validation='dodId') }}
{% endmacro %} diff --git a/templates/task_orders/new/oversight.html b/templates/task_orders/new/oversight.html index 42c316e9..59ea3797 100644 --- a/templates/task_orders/new/oversight.html +++ b/templates/task_orders/new/oversight.html @@ -1,6 +1,7 @@ {% extends 'task_orders/_new.html' %} {% from "components/user_info.html" import UserInfo %} +{% from "components/checkbox_input.html" import CheckboxInput %} {% block heading %} Oversight @@ -12,13 +13,15 @@

Contracting Officer (KO) Information

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

Contractive Officer Representative (COR) Information

{{ UserInfo(form.cor_first_name, form.cor_last_name, form.cor_email, form.cor_dod_id) }} +{{ CheckboxInput(form.cor_invite) }}

Security Officer Information

{{ UserInfo(form.so_first_name, form.so_last_name, form.so_email, form.so_dod_id) }} +{{ CheckboxInput(form.so_invite) }} {% endblock %} diff --git a/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index 67d0b040..8966d59b 100644 --- a/tests/domain/test_task_orders.py +++ b/tests/domain/test_task_orders.py @@ -1,8 +1,8 @@ import pytest -from atst.domain.task_orders import TaskOrders +from atst.domain.task_orders import TaskOrders, TaskOrderError -from tests.factories import TaskOrderFactory +from tests.factories import TaskOrderFactory, UserFactory def test_is_section_complete(): @@ -29,3 +29,34 @@ def test_all_sections_complete(): assert not TaskOrders.all_sections_complete(task_order) task_order.scope = "str12345" assert TaskOrders.all_sections_complete(task_order) + + +def test_add_officer(): + task_order = TaskOrderFactory.create() + ko = UserFactory.create() + owner = task_order.workspace.owner + TaskOrders.add_officer(owner, task_order, "contracting_officer", ko.to_dictionary()) + + assert task_order.contracting_officer == ko + workspace_users = [ws_role.user for ws_role in task_order.workspace.members] + assert ko in workspace_users + + +def test_add_officer_with_nonexistent_role(): + task_order = TaskOrderFactory.create() + ko = UserFactory.create() + owner = task_order.workspace.owner + with pytest.raises(TaskOrderError): + TaskOrders.add_officer(owner, task_order, "pilot", ko.to_dictionary()) + + +def test_add_officer_who_is_already_workspace_member(): + task_order = TaskOrderFactory.create() + owner = task_order.workspace.owner + TaskOrders.add_officer( + owner, task_order, "contracting_officer", owner.to_dictionary() + ) + + assert task_order.contracting_officer == owner + member = task_order.workspace.members[0] + assert member.user == owner and member.role_name == "owner" diff --git a/tests/routes/task_orders/test_new_task_order.py b/tests/routes/task_orders/test_new_task_order.py index 09412b3d..462fabea 100644 --- a/tests/routes/task_orders/test_new_task_order.py +++ b/tests/routes/task_orders/test_new_task_order.py @@ -4,7 +4,7 @@ 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 +from tests.factories import UserFactory, TaskOrderFactory, WorkspaceFactory def test_new_task_order(client, user_session): @@ -129,3 +129,72 @@ def test_update_task_order_with_existing_task_order(): 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"] + + +def test_invite_officers_to_task_order(queue): + user = UserFactory.create() + workspace = WorkspaceFactory.create(owner=user) + task_order = TaskOrderFactory.create(creator=user, workspace=workspace) + to_data = { + **TaskOrderFactory.dictionary(), + "ko_invite": True, + "cor_invite": True, + "so_invite": True, + } + workflow = UpdateTaskOrderWorkflow( + to_data, user, screen=3, task_order_id=task_order.id + ) + 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 roles.count("officer") == 3 + # 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_add_officer_but_do_not_invite(queue): + user = UserFactory.create() + workspace = WorkspaceFactory.create(owner=user) + task_order = TaskOrderFactory.create(creator=user, workspace=workspace) + to_data = { + **TaskOrderFactory.dictionary(), + "ko_invite": False, + "cor_invite": False, + "so_invite": False, + } + workflow = UpdateTaskOrderWorkflow( + to_data, user, screen=3, task_order_id=task_order.id + ) + workflow.update() + workspace = task_order.workspace + # owner is only workspace member + assert len(workspace.members) == 1 + # no invitations are enqueued + assert len(queue.get_queue()) == 0 + + +def test_update_does_not_resend_invitation(): + user = UserFactory.create() + contracting_officer = UserFactory.create() + workspace = WorkspaceFactory.create(owner=user) + task_order = TaskOrderFactory.create( + creator=user, + workspace=workspace, + ko_first_name=contracting_officer.first_name, + ko_last_name=contracting_officer.last_name, + ko_dod_id=contracting_officer.dod_id, + ) + to_data = {**task_order.to_dictionary(), "ko_invite": True} + workflow = UpdateTaskOrderWorkflow( + to_data, user, screen=3, task_order_id=task_order.id + ) + for i in range(2): + workflow.update() + assert len(contracting_officer.invitations) == 1 diff --git a/tests/routes/workspaces/test_invitations.py b/tests/routes/workspaces/test_invitations.py index d6e99ee0..0d0b4419 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,41 @@ 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"], + "so_dod_id": task_order.so_dod_id, + "cor_dod_id": task_order.cor_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 diff --git a/tests/services/test_invitation.py b/tests/services/test_invitation.py new file mode 100644 index 00000000..cbb5dc2f --- /dev/null +++ b/tests/services/test_invitation.py @@ -0,0 +1,14 @@ +from tests.factories import UserFactory, WorkspaceFactory, WorkspaceRoleFactory + +from atst.services.invitation import Invitation + + +def test_invite_member(queue): + inviter = UserFactory.create() + new_member = UserFactory.create() + workspace = WorkspaceFactory.create(owner=inviter) + ws_member = WorkspaceRoleFactory.create(user=new_member, workspace=workspace) + invite_service = Invitation(inviter, ws_member, new_member.email) + new_invitation = invite_service.invite() + assert new_invitation == new_member.invitations[0] + assert len(queue.get_queue()) == 1