Task order invites #162554089
This commit is contained in:
dandds 2019-01-08 16:59:57 -05:00 committed by GitHub
commit 6429043fca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 425 additions and 16 deletions

View File

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

View File

@ -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": [],
},
]

View File

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

View File

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

View File

@ -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.
<i>You may choose to skip this for now and invite them later.</i>
""",
)
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. <i>You may choose to skip this for
now and invite them later.</i>
""",
)
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. <i>You may choose to skip this for now
and invite them later.</i>
""",
)
class ReviewForm(CacheableForm):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@
</div>
<div class='form-col form-col--half'>
{{ TextInput(dod_id, placeholder='1234567890') }}
{{ TextInput(dod_id, placeholder='1234567890', validation='dodId') }}
</div>
</div>
{% endmacro %}

View File

@ -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 @@
<!-- Oversight Section -->
<h3>Contracting Officer (KO) Information</h3>
{{ UserInfo(form.ko_first_name, form.ko_last_name, form.ko_email, form.ko_dod_id) }}
{{ CheckboxInput(form.ko_invite) }}
<h3>Contractive Officer Representative (COR) Information</h3>
{{ UserInfo(form.cor_first_name, form.cor_last_name, form.cor_email, form.cor_dod_id) }}
{{ CheckboxInput(form.cor_invite) }}
<h3>Security Officer Information</h3>
{{ UserInfo(form.so_first_name, form.so_last_name, form.so_email, form.so_dod_id) }}
{{ CheckboxInput(form.so_invite) }}
{% endblock %}

View File

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

View File

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

View File

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

View File

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