diff --git a/alembic/versions/c98adf9bb431_record_invitation_status.py b/alembic/versions/c98adf9bb431_record_invitation_status.py new file mode 100644 index 00000000..f017beef --- /dev/null +++ b/alembic/versions/c98adf9bb431_record_invitation_status.py @@ -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 ### diff --git a/atst/models/task_order.py b/atst/models/task_order.py index a139a34f..4f43b206 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -2,7 +2,7 @@ from enum import Enum from datetime import date 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.types import ARRAY from sqlalchemy.orm import relationship @@ -62,16 +62,19 @@ class TaskOrder(Base, mixins.TimestampsMixin): ko_email = Column(String) # Email ko_phone_number = Column(String) # Phone Number ko_dod_id = Column(String) # DOD ID + ko_invite = Column(Boolean) cor_first_name = Column(String) # First Name cor_last_name = Column(String) # Last Name cor_email = Column(String) # Email cor_phone_number = Column(String) # Phone Number cor_dod_id = Column(String) # DOD ID + cor_invite = Column(Boolean) so_first_name = Column(String) # First Name so_last_name = Column(String) # Last Name so_email = Column(String) # Email so_phone_number = Column(String) # Phone Number so_dod_id = Column(String) # DOD ID + so_invite = Column(Boolean) pdf_attachment_id = Column(ForeignKey("attachments.id")) _pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id]) number = Column(String, unique=True) # Task Order Number @@ -156,6 +159,44 @@ class TaskOrder(Base, mixins.TimestampsMixin): def is_pending(self): 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): return { "portfolio_name": self.portfolio_name, diff --git a/atst/routes/task_orders/invite.py b/atst/routes/task_orders/invite.py index ac40e0c2..8d453951 100644 --- a/atst/routes/task_orders/invite.py +++ b/atst/routes/task_orders/invite.py @@ -3,17 +3,68 @@ from flask import redirect, url_for, g from . import task_orders_bp from atst.domain.task_orders import TaskOrders 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/", methods=["POST"]) def invite(task_order_id): task_order = TaskOrders.get(g.current_user, task_order_id) - portfolio = task_order.portfolio - flash("task_order_congrats", portfolio=portfolio) - return redirect( - url_for( - "portfolios.view_task_order", - portfolio_id=task_order.portfolio_id, - task_order_id=task_order.id, + if TaskOrders.all_sections_complete(task_order): + update_officer_invitations(g.current_user, task_order) + + portfolio = task_order.portfolio + flash("task_order_congrats", portfolio=portfolio) + return redirect( + 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) ) - ) diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index 8892ba05..894885e9 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -12,9 +12,7 @@ from flask import ( from . import task_orders_bp from atst.domain.task_orders import TaskOrders from atst.domain.portfolios import Portfolios -from atst.domain.portfolio_roles import PortfolioRoles import atst.forms.task_order as task_order_form -from atst.services.invitation import Invitation as InvitationService TASK_ORDER_SECTIONS = [ @@ -173,7 +171,7 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): def validate(self): return self.form.validate() - def _update_task_order(self): + def update(self): if self.task_order: if "portfolio_name" in self.form.data: new_name = self.form.data["portfolio_name"] @@ -189,65 +187,6 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): self._task_order = TaskOrders.create(portfolio=pf, creator=self.user) 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 diff --git a/atst/utils/flash.py b/atst/utils/flash.py index a64693e0..b0100dd0 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -128,6 +128,13 @@ MESSAGES = { """, "category": "success", }, + "task_order_incomplete": { + "title_template": "Task Order Incomplete", + "message_template": """ + You must complete your Task Order form before submitting. + """, + "category": "error", + }, } diff --git a/styles/elements/_icons.scss b/styles/elements/_icons.scss index 5703356d..df42642c 100644 --- a/styles/elements/_icons.scss +++ b/styles/elements/_icons.scss @@ -70,4 +70,8 @@ &.icon--medium { @include icon-size(12); } + + &.icon--gold { + @include icon-color($color-gold-dark); + } } diff --git a/styles/sections/_task_order.scss b/styles/sections/_task_order.scss index f916a0b7..c9bb769b 100644 --- a/styles/sections/_task_order.scss +++ b/styles/sections/_task_order.scss @@ -230,14 +230,18 @@ } .task-order-invite-message { + font-weight: $font-bold; + &.not-sent { color: $color-red; - font-weight: $font-bold; } &.sent { color: $color-green; - font-weight: $font-bold; + } + + &.pending { + color: $color-gold-dark; } } diff --git a/templates/fragments/task_order_review/oversight.html b/templates/fragments/task_order_review/oversight.html index cc519892..cdeb883e 100644 --- a/templates/fragments/task_order_review/oversight.html +++ b/templates/fragments/task_order_review/oversight.html @@ -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) %}

{{ heading | translate }}

- {{ first_name }} {{ last_name }}
- {{ email }}
- {% if phone_number %} - {{ phone_number | usPhone }} + {{ officer_data.first_name }} {{ officer_data.last_name }}
+ {{ officer_data.email }}
+ {% if officer_data.phone_number %} + {{ officer_data.phone_number | usPhone }} {% endif %}
- {{ "task_orders.new.review.dod_id" | translate }} {{ dod_id}}
- {% if officer %} + {{ "task_orders.new.review.dod_id" | translate }} {{ officer_data.dod_id}}
+ {% if has_officer %} {{ Icon('ok', classes='icon--green') }} {{ "task_orders.new.review.invited"| translate }} + {% elif invite_pending %} + {{ Icon('alert', classes='icon--gold') }} {{ "task_orders.new.review.pending_to"| translate }} {% else %} {{ Icon('alert', classes='icon--red') }} {{ "task_orders.new.review.not_invited"| translate }} {% endif %} @@ -17,9 +19,24 @@ {% endmacro %}
- {{ 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("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) }} -
-
- {{ 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) }} -
+ {{ ReviewOfficerInfo( + "task_orders.new.review.ko", + task_order.officer_dictionary("contracting_officer"), + task_order.contracting_officer, + task_order.ko_invitable + ) }} + {{ ReviewOfficerInfo( + "task_orders.new.review.cor", + task_order.officer_dictionary("contracting_officer_representative"), + task_order.contracting_officer_representative, + task_order.cor_invitable + ) }} +
+
+ {{ ReviewOfficerInfo( + "task_orders.new.review.so", + task_order.officer_dictionary("security_officer"), + task_order.security_officer, + task_order.so_invitable + ) }} +
diff --git a/tests/routes/portfolios/test_invitations.py b/tests/routes/portfolios/test_invitations.py index 82dc697b..4e382127 100644 --- a/tests/routes/portfolios/test_invitations.py +++ b/tests/routes/portfolios/test_invitations.py @@ -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): portfolio = PortfolioFactory.create() - task_order = TaskOrderFactory.create(portfolio=portfolio) 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 user_session(portfolio.owner) - client.post( - 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, - }, - ) + client.post(url_for("task_orders.invite", task_order_id=task_order.id)) # contracting officer accepts invitation user = Users.get_by_dod_id(user_info["dod_id"]) diff --git a/tests/routes/task_orders/test_invite.py b/tests/routes/task_orders/test_invite.py index 6ee9fa30..cd07db23 100644 --- a/tests/routes/task_orders/test_invite.py +++ b/tests/routes/task_orders/test_invite.py @@ -1,7 +1,7 @@ import pytest from flask import url_for -from tests.factories import PortfolioFactory, TaskOrderFactory +from tests.factories import PortfolioFactory, TaskOrderFactory, UserFactory 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 ) 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 diff --git a/tests/routes/task_orders/test_new_task_order.py b/tests/routes/task_orders/test_new_task_order.py index 6e78a6f1..726b3fae 100644 --- a/tests/routes/task_orders/test_new_task_order.py +++ b/tests/routes/task_orders/test_new_task_order.py @@ -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 -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): user_session(task_order.creator) diff --git a/translations.yaml b/translations.yaml index f9a00d8c..983db1b9 100644 --- a/translations.yaml +++ b/translations.yaml @@ -458,6 +458,7 @@ task_orders: invited: Invited not_invited: Not Yet Invited not_uploaded: Not Uploaded + pending_to: Pending TO Completion invitations: dod_id_label: DoD ID contracting_officer: