Merge pull request #616 from dod-ccpo/blank-officer-email

Send officer invitations after TO form complete
This commit is contained in:
dandds 2019-02-11 16:08:11 -05:00 committed by GitHub
commit 40526b4a3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 269 additions and 167 deletions

View File

@ -0,0 +1,32 @@
"""record invitation status
Revision ID: c98adf9bb431
Revises: 1f690989e38e
Create Date: 2019-02-06 09:02:28.617202
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c98adf9bb431'
down_revision = '1f690989e38e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('task_orders', sa.Column('cor_invite', sa.Boolean(), nullable=True))
op.add_column('task_orders', sa.Column('ko_invite', sa.Boolean(), nullable=True))
op.add_column('task_orders', sa.Column('so_invite', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('task_orders', 'so_invite')
op.drop_column('task_orders', 'ko_invite')
op.drop_column('task_orders', 'cor_invite')
# ### end Alembic commands ###

View File

@ -2,7 +2,7 @@ from enum import Enum
from datetime import date from datetime import date
import pendulum import pendulum
from sqlalchemy import Column, Numeric, String, ForeignKey, Date, Integer from sqlalchemy import Boolean, Column, Numeric, String, ForeignKey, Date, Integer
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.types import ARRAY from sqlalchemy.types import ARRAY
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@ -62,16 +62,19 @@ class TaskOrder(Base, mixins.TimestampsMixin):
ko_email = Column(String) # Email ko_email = Column(String) # Email
ko_phone_number = Column(String) # Phone Number ko_phone_number = Column(String) # Phone Number
ko_dod_id = Column(String) # DOD ID ko_dod_id = Column(String) # DOD ID
ko_invite = Column(Boolean)
cor_first_name = Column(String) # First Name cor_first_name = Column(String) # First Name
cor_last_name = Column(String) # Last Name cor_last_name = Column(String) # Last Name
cor_email = Column(String) # Email cor_email = Column(String) # Email
cor_phone_number = Column(String) # Phone Number cor_phone_number = Column(String) # Phone Number
cor_dod_id = Column(String) # DOD ID cor_dod_id = Column(String) # DOD ID
cor_invite = Column(Boolean)
so_first_name = Column(String) # First Name so_first_name = Column(String) # First Name
so_last_name = Column(String) # Last Name so_last_name = Column(String) # Last Name
so_email = Column(String) # Email so_email = Column(String) # Email
so_phone_number = Column(String) # Phone Number so_phone_number = Column(String) # Phone Number
so_dod_id = Column(String) # DOD ID so_dod_id = Column(String) # DOD ID
so_invite = Column(Boolean)
pdf_attachment_id = Column(ForeignKey("attachments.id")) pdf_attachment_id = Column(ForeignKey("attachments.id"))
_pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id]) _pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id])
number = Column(String, unique=True) # Task Order Number number = Column(String, unique=True) # Task Order Number
@ -156,6 +159,44 @@ class TaskOrder(Base, mixins.TimestampsMixin):
def is_pending(self): def is_pending(self):
return self.status == Status.PENDING return self.status == Status.PENDING
@property
def ko_invitable(self):
"""
The MO has indicated that the KO should be invited but we have not sent
an invite and attached the KO user
"""
return self.ko_invite and not self.contracting_officer
@property
def cor_invitable(self):
"""
The MO has indicated that the COR should be invited but we have not sent
an invite and attached the COR user
"""
return self.cor_invite and not self.contracting_officer_representative
@property
def so_invitable(self):
"""
The MO has indicated that the SO should be invited but we have not sent
an invite and attached the SO user
"""
return self.so_invite and not self.security_officer
_OFFICER_PREFIXES = {
"contracting_officer": "ko",
"contracting_officer_representative": "cor",
"security_officer": "so",
}
_OFFICER_PROPERTIES = ["first_name", "last_name", "phone_number", "email", "dod_id"]
def officer_dictionary(self, officer_type):
prefix = self._OFFICER_PREFIXES[officer_type]
return {
field: getattr(self, "{}_{}".format(prefix, field))
for field in self._OFFICER_PROPERTIES
}
def to_dictionary(self): def to_dictionary(self):
return { return {
"portfolio_name": self.portfolio_name, "portfolio_name": self.portfolio_name,

View File

@ -3,17 +3,68 @@ from flask import redirect, url_for, g
from . import task_orders_bp from . import task_orders_bp
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.utils.flash import formatted_flash as flash from atst.utils.flash import formatted_flash as flash
from atst.domain.portfolio_roles import PortfolioRoles
from atst.services.invitation import Invitation as InvitationService
OFFICER_INVITATIONS = [
{
"field": "ko_invite",
"role": "contracting_officer",
"subject": "Review a task order",
"template": "emails/invitation.txt",
},
{
"field": "cor_invite",
"role": "contracting_officer_representative",
"subject": "Help with a task order",
"template": "emails/invitation.txt",
},
{
"field": "so_invite",
"role": "security_officer",
"subject": "Review security for a task order",
"template": "emails/invitation.txt",
},
]
def update_officer_invitations(user, task_order):
for officer_type in OFFICER_INVITATIONS:
field = officer_type["field"]
if getattr(task_order, field) and not getattr(task_order, officer_type["role"]):
officer_data = task_order.officer_dictionary(officer_type["role"])
officer = TaskOrders.add_officer(
user, task_order, officer_type["role"], officer_data
)
pf_officer_member = PortfolioRoles.get(task_order.portfolio.id, officer.id)
invite_service = InvitationService(
user,
pf_officer_member,
officer_data["email"],
subject=officer_type["subject"],
email_template=officer_type["template"],
)
invite_service.invite()
@task_orders_bp.route("/task_orders/invite/<task_order_id>", methods=["POST"]) @task_orders_bp.route("/task_orders/invite/<task_order_id>", methods=["POST"])
def invite(task_order_id): def invite(task_order_id):
task_order = TaskOrders.get(g.current_user, task_order_id) task_order = TaskOrders.get(g.current_user, task_order_id)
portfolio = task_order.portfolio if TaskOrders.all_sections_complete(task_order):
flash("task_order_congrats", portfolio=portfolio) update_officer_invitations(g.current_user, task_order)
return redirect(
url_for( portfolio = task_order.portfolio
"portfolios.view_task_order", flash("task_order_congrats", portfolio=portfolio)
portfolio_id=task_order.portfolio_id, return redirect(
task_order_id=task_order.id, url_for(
"portfolios.view_task_order",
portfolio_id=task_order.portfolio_id,
task_order_id=task_order.id,
)
)
else:
flash("task_order_incomplete")
return redirect(
url_for("task_orders.new", screen=4, task_order_id=task_order.id)
) )
)

View File

@ -12,9 +12,7 @@ from flask import (
from . import task_orders_bp from . import task_orders_bp
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.domain.portfolios import Portfolios from atst.domain.portfolios import Portfolios
from atst.domain.portfolio_roles import PortfolioRoles
import atst.forms.task_order as task_order_form import atst.forms.task_order as task_order_form
from atst.services.invitation import Invitation as InvitationService
TASK_ORDER_SECTIONS = [ TASK_ORDER_SECTIONS = [
@ -173,7 +171,7 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow):
def validate(self): def validate(self):
return self.form.validate() return self.form.validate()
def _update_task_order(self): def update(self):
if self.task_order: if self.task_order:
if "portfolio_name" in self.form.data: if "portfolio_name" in self.form.data:
new_name = self.form.data["portfolio_name"] new_name = self.form.data["portfolio_name"]
@ -189,65 +187,6 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow):
self._task_order = TaskOrders.create(portfolio=pf, creator=self.user) self._task_order = TaskOrders.create(portfolio=pf, creator=self.user)
TaskOrders.update(self.user, self.task_order, **self.task_order_form_data) TaskOrders.update(self.user, self.task_order, **self.task_order_form_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",
"phone_number",
"dod_id",
]
}
officer = TaskOrders.add_officer(
self.user, self.task_order, officer_type["role"], officer_data
)
pf_officer_member = PortfolioRoles.get(self.portfolio.id, officer.id)
invite_service = InvitationService(
self.user,
pf_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 return self.task_order

View File

@ -128,6 +128,13 @@ MESSAGES = {
""", """,
"category": "success", "category": "success",
}, },
"task_order_incomplete": {
"title_template": "Task Order Incomplete",
"message_template": """
You must complete your Task Order form before submitting.
""",
"category": "error",
},
} }

View File

@ -70,4 +70,8 @@
&.icon--medium { &.icon--medium {
@include icon-size(12); @include icon-size(12);
} }
&.icon--gold {
@include icon-color($color-gold-dark);
}
} }

View File

@ -230,14 +230,18 @@
} }
.task-order-invite-message { .task-order-invite-message {
font-weight: $font-bold;
&.not-sent { &.not-sent {
color: $color-red; color: $color-red;
font-weight: $font-bold;
} }
&.sent { &.sent {
color: $color-green; color: $color-green;
font-weight: $font-bold; }
&.pending {
color: $color-gold-dark;
} }
} }

View File

@ -1,15 +1,17 @@
{% macro ReviewOfficerInfo(heading, first_name, last_name, email, phone_number, dod_id, officer) %} {% macro ReviewOfficerInfo(heading, officer_data, has_officer, invite_pending) %}
<div class="col col--grow"> <div class="col col--grow">
<h4 class='task-order-form__heading'>{{ heading | translate }}</h4> <h4 class='task-order-form__heading'>{{ heading | translate }}</h4>
{{ first_name }} {{ last_name }}<br> {{ officer_data.first_name }} {{ officer_data.last_name }}<br>
{{ email }}<br> {{ officer_data.email }}<br>
{% if phone_number %} {% if officer_data.phone_number %}
{{ phone_number | usPhone }} {{ officer_data.phone_number | usPhone }}
{% endif %} {% endif %}
<br> <br>
{{ "task_orders.new.review.dod_id" | translate }} {{ dod_id}}<br> {{ "task_orders.new.review.dod_id" | translate }} {{ officer_data.dod_id}}<br>
{% if officer %} {% if has_officer %}
{{ Icon('ok', classes='icon--green') }} <span class="task-order-invite-message sent">{{ "task_orders.new.review.invited"| translate }}</<span> {{ Icon('ok', classes='icon--green') }} <span class="task-order-invite-message sent">{{ "task_orders.new.review.invited"| translate }}</<span>
{% elif invite_pending %}
{{ Icon('alert', classes='icon--gold') }} <span class="task-order-invite-message pending">{{ "task_orders.new.review.pending_to"| translate }}</<span>
{% else %} {% else %}
{{ Icon('alert', classes='icon--red') }} <span class="task-order-invite-message not-sent">{{ "task_orders.new.review.not_invited"| translate }}</span> {{ Icon('alert', classes='icon--red') }} <span class="task-order-invite-message not-sent">{{ "task_orders.new.review.not_invited"| translate }}</span>
{% endif %} {% endif %}
@ -17,9 +19,24 @@
{% endmacro %} {% endmacro %}
<div class="row"> <div class="row">
{{ ReviewOfficerInfo("task_orders.new.review.ko", task_order.ko_first_name, task_order.ko_last_name, task_order.ko_email, task_order.ko_phone_number, task_order.ko_dod_id, task_order.contracting_officer) }} {{ ReviewOfficerInfo(
{{ ReviewOfficerInfo("task_orders.new.review.cor", task_order.cor_first_name, task_order.cor_last_name, task_order.cor_email, task_order.cor_phone_number, task_order.cor_dod_id, task_order.contracting_officer_representative) }} "task_orders.new.review.ko",
</div> task_order.officer_dictionary("contracting_officer"),
<div class="row"> task_order.contracting_officer,
{{ ReviewOfficerInfo("task_orders.new.review.so", task_order.so_first_name, task_order.so_last_name, task_order.so_email, task_order.so_phone_number, task_order.so_dod_id, task_order.security_officer) }} task_order.ko_invitable
</div> ) }}
{{ ReviewOfficerInfo(
"task_orders.new.review.cor",
task_order.officer_dictionary("contracting_officer_representative"),
task_order.contracting_officer_representative,
task_order.cor_invitable
) }}
</div>
<div class="row">
{{ ReviewOfficerInfo(
"task_orders.new.review.so",
task_order.officer_dictionary("security_officer"),
task_order.security_officer,
task_order.so_invitable
) }}
</div>

View File

@ -212,27 +212,20 @@ def test_existing_member_invite_resent_to_email_submitted_in_form(
def test_contracting_officer_accepts_invite(monkeypatch, client, user_session): def test_contracting_officer_accepts_invite(monkeypatch, client, user_session):
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
task_order = TaskOrderFactory.create(portfolio=portfolio)
user_info = UserFactory.dictionary() user_info = UserFactory.dictionary()
task_order = TaskOrderFactory.create(
portfolio=portfolio,
ko_first_name=user_info["first_name"],
ko_last_name=user_info["last_name"],
ko_email=user_info["email"],
ko_phone_number=user_info["phone_number"],
ko_dod_id=user_info["dod_id"],
ko_invite=True,
)
# create contracting officer # create contracting officer
user_session(portfolio.owner) user_session(portfolio.owner)
client.post( client.post(url_for("task_orders.invite", task_order_id=task_order.id))
url_for("task_orders.new", screen=3, task_order_id=task_order.id),
data={
"portfolio_role": "contracting_officer",
"ko_first_name": user_info["first_name"],
"ko_last_name": user_info["last_name"],
"ko_email": user_info["email"],
"ko_phone_number": user_info["phone_number"],
"ko_dod_id": user_info["dod_id"],
"cor_phone_number": user_info["phone_number"],
"so_phone_number": user_info["phone_number"],
"so_dod_id": task_order.so_dod_id,
"cor_dod_id": task_order.cor_dod_id,
"ko_invite": True,
},
)
# contracting officer accepts invitation # contracting officer accepts invitation
user = Users.get_by_dod_id(user_info["dod_id"]) user = Users.get_by_dod_id(user_info["dod_id"])

View File

@ -1,7 +1,7 @@
import pytest import pytest
from flask import url_for from flask import url_for
from tests.factories import PortfolioFactory, TaskOrderFactory from tests.factories import PortfolioFactory, TaskOrderFactory, UserFactory
def test_invite(client, user_session): def test_invite(client, user_session):
@ -15,3 +15,79 @@ def test_invite(client, user_session):
"portfolios.view_task_order", portfolio_id=to.portfolio_id, task_order_id=to.id "portfolios.view_task_order", portfolio_id=to.portfolio_id, task_order_id=to.id
) )
assert redirect in response.headers["Location"] assert redirect in response.headers["Location"]
def test_invite_officers_to_task_order(client, user_session, queue):
task_order = TaskOrderFactory.create(
ko_invite=True, cor_invite=True, so_invite=True
)
portfolio = task_order.portfolio
user_session(portfolio.owner)
client.post(url_for("task_orders.invite", task_order_id=task_order.id))
# owner and three officers are portfolio members
assert len(portfolio.members) == 4
roles = [member.role.name for member in portfolio.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 == task_order.ko_dod_id
assert task_order.contracting_officer_representative.dod_id == task_order.cor_dod_id
assert task_order.security_officer.dod_id == task_order.so_dod_id
def test_add_officer_but_do_not_invite(client, user_session, queue):
task_order = TaskOrderFactory.create(
ko_invite=False, cor_invite=False, so_invite=False
)
portfolio = task_order.portfolio
user_session(portfolio.owner)
client.post(url_for("task_orders.invite", task_order_id=task_order.id))
portfolio = task_order.portfolio
# owner is only portfolio member
assert len(portfolio.members) == 1
# no invitations are enqueued
assert len(queue.get_queue()) == 0
def test_does_not_resend_officer_invitation(client, user_session):
user = UserFactory.create()
contracting_officer = UserFactory.create()
portfolio = PortfolioFactory.create(owner=user)
task_order = TaskOrderFactory.create(
creator=user,
portfolio=portfolio,
ko_first_name=contracting_officer.first_name,
ko_last_name=contracting_officer.last_name,
ko_dod_id=contracting_officer.dod_id,
ko_invite=True,
)
user_session(user)
for i in range(2):
client.post(url_for("task_orders.invite", task_order_id=task_order.id))
assert len(contracting_officer.invitations) == 1
def test_does_not_invite_if_task_order_incomplete(client, user_session, queue):
task_order = TaskOrderFactory.create(
scope=None, ko_invite=True, cor_invite=True, so_invite=True
)
portfolio = task_order.portfolio
user_session(portfolio.owner)
response = client.post(url_for("task_orders.invite", task_order_id=task_order.id))
# redirected to review screen
assert response.headers["Location"] == url_for(
"task_orders.new", screen=4, task_order_id=task_order.id, _external=True
)
# only owner is portfolio member
assert len(portfolio.members) == 1
# no email invitations are enqueued
assert len(queue.get_queue()) == 0

View File

@ -235,69 +235,6 @@ def test_cor_data_set_to_user_data_if_am_cor_is_checked(task_order):
assert task_order.cor_dod_id == task_order.creator.dod_id assert task_order.cor_dod_id == task_order.creator.dod_id
def test_invite_officers_to_task_order(task_order, queue):
to_data = {
**TaskOrderFactory.dictionary(),
"ko_invite": True,
"cor_invite": True,
"so_invite": True,
}
workflow = UpdateTaskOrderWorkflow(
task_order.creator, to_data, screen=3, task_order_id=task_order.id
)
workflow.update()
portfolio = task_order.portfolio
# owner and three officers are portfolio members
assert len(portfolio.members) == 4
roles = [member.role.name for member in portfolio.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(task_order, queue):
to_data = {
**TaskOrderFactory.dictionary(),
"ko_invite": False,
"cor_invite": False,
"so_invite": False,
}
workflow = UpdateTaskOrderWorkflow(
task_order.creator, to_data, screen=3, task_order_id=task_order.id
)
workflow.update()
portfolio = task_order.portfolio
# owner is only portfolio member
assert len(portfolio.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()
portfolio = PortfolioFactory.create(owner=user)
task_order = TaskOrderFactory.create(
creator=user,
portfolio=portfolio,
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(
user, to_data, screen=3, task_order_id=task_order.id
)
for i in range(2):
workflow.update()
assert len(contracting_officer.invitations) == 1
def test_review_task_order_form(client, user_session, task_order): def test_review_task_order_form(client, user_session, task_order):
user_session(task_order.creator) user_session(task_order.creator)

View File

@ -458,6 +458,7 @@ task_orders:
invited: Invited invited: Invited
not_invited: Not Yet Invited not_invited: Not Yet Invited
not_uploaded: Not Uploaded not_uploaded: Not Uploaded
pending_to: Pending TO Completion
invitations: invitations:
dod_id_label: DoD ID dod_id_label: DoD ID
contracting_officer: contracting_officer: