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 @@