Merge pull request #884 from dod-ccpo/to-funding-statuses

Funding page Task Order statuses
This commit is contained in:
richard-dds 2019-06-10 15:40:25 -04:00 committed by GitHub
commit 0bd9d4bbb6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 280 additions and 92 deletions

View File

@ -2,7 +2,7 @@ from flask import current_app as app
from atst.database import db from atst.database import db
from atst.models.clin import CLIN from atst.models.clin import CLIN
from atst.models.task_order import TaskOrder from atst.models.task_order import TaskOrder, SORT_ORDERING
from . import BaseDomainClass from . import BaseDomainClass
@ -98,3 +98,10 @@ class TaskOrders(BaseDomainClass):
if not app.config.get("CLASSIFIED"): if not app.config.get("CLASSIFIED"):
section_list["funding"] = TaskOrders.UNCLASSIFIED_FUNDING section_list["funding"] = TaskOrders.UNCLASSIFIED_FUNDING
return section_list return section_list
@classmethod
def sort(cls, task_orders: [TaskOrder]) -> [TaskOrder]:
# Sorts a list of task orders on two keys: status (primary) and time_created (secondary)
by_time_created = sorted(task_orders, key=lambda to: to.time_created)
by_status = sorted(by_time_created, key=lambda to: SORT_ORDERING.get(to.status))
return by_status

View File

@ -1,5 +1,4 @@
from enum import Enum from enum import Enum
from datetime import date
from sqlalchemy import Column, DateTime, ForeignKey, String from sqlalchemy import Column, DateTime, ForeignKey, String
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
@ -8,13 +7,23 @@ from werkzeug.datastructures import FileStorage
from atst.models import Attachment, Base, mixins, types from atst.models import Attachment, Base, mixins, types
from atst.models.clin import JEDICLINType from atst.models.clin import JEDICLINType
from atst.utils.clock import Clock
class Status(Enum): class Status(Enum):
STARTED = "Started" DRAFT = "Draft"
PENDING = "Pending"
ACTIVE = "Active" ACTIVE = "Active"
UPCOMING = "Upcoming"
EXPIRED = "Expired" EXPIRED = "Expired"
UNSIGNED = "Not signed"
SORT_ORDERING = {
status: order
for (order, status) in enumerate(
[Status.DRAFT, Status.ACTIVE, Status.UPCOMING, Status.EXPIRED, Status.UNSIGNED]
)
}
class TaskOrder(Base, mixins.TimestampsMixin): class TaskOrder(Base, mixins.TimestampsMixin):
@ -62,28 +71,41 @@ class TaskOrder(Base, mixins.TimestampsMixin):
def is_expired(self): def is_expired(self):
return self.status == Status.EXPIRED return self.status == Status.EXPIRED
@property
def is_completed(self):
return all([self.pdf, self.number, len(self.clins)])
@property
def is_signed(self):
return self.signed_at is not None
@property @property
def status(self): def status(self):
# TODO: fix task order -- implement correctly using CLINs today = Clock.today()
# Faked for display purposes
return Status.ACTIVE if not self.is_completed and not self.is_signed:
return Status.DRAFT
elif self.is_completed and not self.is_signed:
return Status.UNSIGNED
elif today < self.start_date:
return Status.UPCOMING
elif today >= self.end_date:
return Status.EXPIRED
elif self.start_date <= today < self.end_date:
return Status.ACTIVE
@property @property
def start_date(self): def start_date(self):
# TODO: fix task order -- reimplement using CLINs return min((c.start_date for c in self.clins), default=self.time_created.date())
# Faked for display purposes
return date.today()
@property @property
def end_date(self): def end_date(self):
# TODO: fix task order -- reimplement using CLINs return max((c.end_date for c in self.clins), default=None)
# Faked for display purposes
return date.today()
@property @property
def days_to_expiration(self): def days_to_expiration(self):
if self.end_date: if self.end_date:
return (self.end_date - date.today()).days return (self.end_date - Clock.today()).days
@property @property
def total_obligated_funds(self): def total_obligated_funds(self):

View File

@ -1,13 +1,11 @@
from collections import defaultdict
from flask import g, render_template from flask import g, render_template
from . import task_orders_bp from . import task_orders_bp
from atst.domain.authz.decorator import user_can_access_decorator as user_can from atst.domain.authz.decorator import user_can_access_decorator as user_can
from atst.domain.portfolios import Portfolios from atst.domain.portfolios import Portfolios
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.models.task_order import Status
from atst.models import Permissions from atst.models import Permissions
from atst.models.task_order import Status as TaskOrderStatus
@task_orders_bp.route("/task_orders/<task_order_id>") @task_orders_bp.route("/task_orders/<task_order_id>")
@ -34,19 +32,16 @@ def review_task_order(task_order_id):
@user_can(Permissions.VIEW_PORTFOLIO_FUNDING, message="view portfolio funding") @user_can(Permissions.VIEW_PORTFOLIO_FUNDING, message="view portfolio funding")
def portfolio_funding(portfolio_id): def portfolio_funding(portfolio_id):
portfolio = Portfolios.get(g.current_user, portfolio_id) portfolio = Portfolios.get(g.current_user, portfolio_id)
task_orders_by_status = defaultdict(list) task_orders = TaskOrders.sort(portfolio.task_orders)
label_colors = {
for task_order in portfolio.task_orders: Status.DRAFT: "warning",
task_orders_by_status[task_order.status].append(task_order) Status.ACTIVE: "success",
Status.UPCOMING: "info",
active_task_orders = task_orders_by_status.get(TaskOrderStatus.ACTIVE, []) Status.EXPIRED: "error",
Status.UNSIGNED: "purple",
}
return render_template( return render_template(
"portfolios/task_orders/index.html", "portfolios/task_orders/index.html",
pending_task_orders=( task_orders=task_orders,
task_orders_by_status.get(TaskOrderStatus.STARTED, []) label_colors=label_colors,
+ task_orders_by_status.get(TaskOrderStatus.PENDING, [])
),
active_task_orders=active_task_orders,
expired_task_orders=task_orders_by_status.get(TaskOrderStatus.EXPIRED, []),
) )

11
atst/utils/clock.py Normal file
View File

@ -0,0 +1,11 @@
import pendulum
class Clock(object):
@classmethod
def today(cls, tz="UTC"):
return pendulum.today(tz=tz).date()
@classmethod
def now(cls, tz="UTC"):
return pendulum.now(tz=tz)

View File

@ -31,5 +31,8 @@ contract = client.get_contract(contract_number=contract_number, status="Y")
requested_clins = ",".join(["'0001'", "'0003'", "'1001'", "'1003'", "'2001'", "'2003'"]) requested_clins = ",".join(["'0001'", "'0003'", "'1001'", "'1003'", "'2001'", "'2003'"])
clins = client.get_clins( clins = client.get_clins(
record_key=contract_number, duns_number="", cage_code="1U305", clins=requested_clins record_key=contract_number,
duns_number="",
cage_code="1U305",
with_clins=requested_clins,
) )

View File

@ -1,7 +1,7 @@
# Add root application dir to the python path # Add root application dir to the python path
import os import os
import sys import sys
from datetime import timedelta, date from datetime import timedelta, date, timedelta
import random import random
from faker import Faker from faker import Faker
@ -31,6 +31,7 @@ from tests.factories import (
random_service_branch, random_service_branch,
random_task_order_number, random_task_order_number,
TaskOrderFactory, TaskOrderFactory,
CLINFactory,
) )
fake = Faker() fake = Faker()
@ -160,16 +161,28 @@ def add_members_to_portfolio(portfolio):
def add_task_orders_to_portfolio(portfolio): def add_task_orders_to_portfolio(portfolio):
# TODO: after CLINs are implemented, vary the start/end dates of TOs today = date.today()
create_task_order(portfolio) future = today + timedelta(days=100)
create_task_order(portfolio) yesterday = today - timedelta(days=1)
create_task_order(portfolio)
draft_to = TaskOrderFactory.build(portfolio=portfolio, pdf=None)
unsigned_to = TaskOrderFactory.build(portfolio=portfolio)
upcoming_to = TaskOrderFactory.build(portfolio=portfolio, signed_at=yesterday)
expired_to = TaskOrderFactory.build(portfolio=portfolio, signed_at=yesterday)
active_to = TaskOrderFactory.build(portfolio=portfolio, signed_at=yesterday)
def create_task_order(portfolio): clins = [
# TODO: after CLINs are implemented add them to TO CLINFactory.build(task_order=unsigned_to, start_date=today, end_date=today),
task_order = TaskOrderFactory.build(portfolio=portfolio) CLINFactory.build(task_order=upcoming_to, start_date=future, end_date=future),
db.session.add(task_order) CLINFactory.build(
task_order=expired_to, start_date=yesterday, end_date=yesterday
),
CLINFactory.build(task_order=active_to, start_date=yesterday, end_date=future),
]
task_orders = [draft_to, unsigned_to, upcoming_to, expired_to, active_to]
db.session.add_all(task_orders + clins)
db.session.commit() db.session.commit()

View File

@ -33,4 +33,8 @@
&.label--success { &.label--success {
background-color: $color-green; background-color: $color-green;
} }
&.label--purple {
background-color: $color-purple;
}
} }

View File

@ -73,6 +73,9 @@
.task-order-card .label { .task-order-card .label {
font-size: $small-font-size; font-size: $small-font-size;
margin-right: 2 * $gap; margin-right: 2 * $gap;
min-width: 7rem;
display: flex;
justify-content: space-around;
} }
.task-order-card__buttons .usa-button { .task-order-card__buttons .usa-button {

View File

@ -45,7 +45,7 @@
{% for task_order in task_orders %} {% for task_order in task_orders %}
<div class="card task-order-card"> <div class="card task-order-card">
<div class="card__status"> <div class="card__status">
<span class='label label--{{ label }}'>{{ task_order.display_status }}</span> <span class='label label--{{ label_colors[task_order.status] }}'>{{ task_order.display_status }}</span>
{{ TaskOrderDate(task_order) }} {{ TaskOrderDate(task_order) }}
<span class="card__status-spacer"></span> <span class="card__status-spacer"></span>
<span class="card__button"> <span class="card__button">
@ -70,7 +70,9 @@
<div class="portfolio-funding"> <div class="portfolio-funding">
{% if not active_task_orders and not pending_task_orders %} {% if task_orders %}
{{ TaskOrderList(task_orders) }}
{% else %}
{{ EmptyState( {{ EmptyState(
'This portfolio doesnt have any active or pending task orders.', 'This portfolio doesnt have any active or pending task orders.',
action_label='Add a New Task Order', action_label='Add a New Task Order',
@ -78,18 +80,6 @@
icon='cloud', icon='cloud',
) }} ) }}
{% endif %} {% endif %}
{% if pending_task_orders %}
{{ TaskOrderList(pending_task_orders, label='warning') }}
{% endif %}
{% if active_task_orders %}
{{ TaskOrderList(active_task_orders, label='success') }}
{% endif %}
{% if expired_task_orders %}
{{ TaskOrderList(expired_task_orders, label='error') }}
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,19 +1,78 @@
import pytest import pytest
from datetime import date from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from atst.domain.task_orders import TaskOrders, TaskOrderError from atst.domain.task_orders import TaskOrders
from atst.domain.exceptions import UnauthorizedError
from atst.domain.permission_sets import PermissionSets
from atst.domain.portfolio_roles import PortfolioRoles
from atst.models.attachment import Attachment from atst.models.attachment import Attachment
from tests.factories import TaskOrderFactory, CLINFactory, PortfolioFactory
from tests.factories import (
TaskOrderFactory, def test_task_order_sorting():
UserFactory, """
PortfolioRoleFactory, Task orders should be listed first by status, and then by time_created.
PortfolioFactory, """
)
today = date.today()
yesterday = today - timedelta(days=1)
future = today + timedelta(days=100)
task_orders = [
# Draft
TaskOrderFactory.create(pdf=None),
TaskOrderFactory.create(pdf=None),
TaskOrderFactory.create(pdf=None),
# Active
TaskOrderFactory.create(
signed_at=yesterday,
clins=[CLINFactory.create(start_date=yesterday, end_date=future)],
),
TaskOrderFactory.create(
signed_at=yesterday,
clins=[CLINFactory.create(start_date=yesterday, end_date=future)],
),
TaskOrderFactory.create(
signed_at=yesterday,
clins=[CLINFactory.create(start_date=yesterday, end_date=future)],
),
# Upcoming
TaskOrderFactory.create(
signed_at=yesterday,
clins=[CLINFactory.create(start_date=future, end_date=future)],
),
TaskOrderFactory.create(
signed_at=yesterday,
clins=[CLINFactory.create(start_date=future, end_date=future)],
),
TaskOrderFactory.create(
signed_at=yesterday,
clins=[CLINFactory.create(start_date=future, end_date=future)],
),
# Expired
TaskOrderFactory.create(
signed_at=yesterday,
clins=[CLINFactory.create(start_date=yesterday, end_date=yesterday)],
),
TaskOrderFactory.create(
signed_at=yesterday,
clins=[CLINFactory.create(start_date=yesterday, end_date=yesterday)],
),
TaskOrderFactory.create(
signed_at=yesterday,
clins=[CLINFactory.create(start_date=yesterday, end_date=yesterday)],
),
# Unsigned
TaskOrderFactory.create(
clins=[CLINFactory.create(start_date=today, end_date=today)]
),
TaskOrderFactory.create(
clins=[CLINFactory.create(start_date=today, end_date=today)]
),
TaskOrderFactory.create(
clins=[CLINFactory.create(start_date=today, end_date=today)]
),
]
assert TaskOrders.sort(task_orders) == task_orders
@pytest.mark.skip(reason="Need to reimplement after new TO form is created") @pytest.mark.skip(reason="Need to reimplement after new TO form is created")
@ -118,7 +177,9 @@ def test_update_adds_clins(pdf_upload):
def test_update_does_not_duplicate_clins(pdf_upload): def test_update_does_not_duplicate_clins(pdf_upload):
task_order = TaskOrderFactory.create(number="3453453456", clins=["123", "456"]) task_order = TaskOrderFactory.create(
number="3453453456", create_clins=["123", "456"]
)
clins = [ clins = [
{ {
"jedi_clin_type": "JEDI_CLIN_1", "jedi_clin_type": "JEDI_CLIN_1",

View File

@ -272,13 +272,14 @@ class TaskOrderFactory(Base):
portfolio = factory.SubFactory(PortfolioFactory) portfolio = factory.SubFactory(PortfolioFactory)
number = factory.LazyFunction(random_task_order_number) number = factory.LazyFunction(random_task_order_number)
creator = factory.SubFactory(UserFactory) creator = factory.SubFactory(UserFactory)
_pdf = factory.SubFactory(AttachmentFactory)
@classmethod @classmethod
def _create(cls, model_class, *args, **kwargs): def _create(cls, model_class, *args, **kwargs):
with_clins = kwargs.pop("clins", []) create_clins = kwargs.pop("create_clins", [])
task_order = super()._create(model_class, *args, **kwargs) task_order = super()._create(model_class, *args, **kwargs)
for clin in with_clins: for clin in create_clins:
CLINFactory.create(task_order=task_order, number=clin) CLINFactory.create(task_order=task_order, number=clin)
return task_order return task_order
@ -293,7 +294,7 @@ class CLINFactory(Base):
start_date = datetime.date.today() start_date = datetime.date.today()
end_date = factory.LazyFunction(random_future_date) end_date = factory.LazyFunction(random_future_date)
obligated_amount = random.randint(100, 999999) obligated_amount = random.randint(100, 999999)
jedi_clin_type = random.choice([e.value for e in clin.JEDICLINType]) jedi_clin_type = random.choice(list(clin.JEDICLINType))
class NotificationRecipientFactory(Base): class NotificationRecipientFactory(Base):

View File

@ -1,40 +1,120 @@
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
import pytest, datetime import pytest
from datetime import date
from unittest.mock import patch, PropertyMock
import pendulum
from atst.models import * from atst.models import *
from atst.models.clin import JEDICLINType from atst.models.clin import JEDICLINType
from atst.models.task_order import TaskOrder, Status from atst.models.task_order import TaskOrder, Status
from tests.factories import ( from tests.factories import CLINFactory, TaskOrderFactory
CLINFactory,
random_future_date,
random_past_date,
TaskOrderFactory,
)
from tests.mocks import PDF_FILENAME from tests.mocks import PDF_FILENAME
def test_period_of_performance_is_first_to_last_clin():
start_date = date(2019, 6, 6)
end_date = date(2020, 6, 6)
intermediate_start_date = date(2019, 7, 1)
intermediate_end_date = date(2020, 3, 1)
task_order = TaskOrderFactory.create(
clins=[
CLINFactory.create(
start_date=intermediate_start_date, end_date=intermediate_end_date
),
CLINFactory.create(start_date=start_date, end_date=intermediate_end_date),
CLINFactory.create(
start_date=intermediate_start_date, end_date=intermediate_end_date
),
CLINFactory.create(start_date=intermediate_start_date, end_date=end_date),
CLINFactory.create(
start_date=intermediate_start_date, end_date=intermediate_end_date
),
]
)
assert task_order.start_date == start_date
assert task_order.end_date == end_date
def test_task_order_completed():
assert TaskOrderFactory.create(clins=[CLINFactory.create()]).is_completed
assert not TaskOrderFactory.create().is_completed
assert not TaskOrderFactory.create(clins=[]).is_completed
assert not TaskOrderFactory.create(number=None).is_completed
class TestTaskOrderStatus: class TestTaskOrderStatus:
@pytest.mark.skip(reason="Reimplement after adding CLINs") @patch("atst.models.TaskOrder.is_completed", new_callable=PropertyMock)
def test_started_status(self): @patch("atst.models.TaskOrder.is_signed", new_callable=PropertyMock)
def test_draft_status(self, is_signed, is_completed):
# Given that I have a TO that is neither completed nor signed
to = TaskOrder() to = TaskOrder()
assert to.status == Status.STARTED is_signed.return_value = False
is_completed.return_value = False
@pytest.mark.skip(reason="See if still needed after implementing CLINs") assert to.status == Status.DRAFT
def test_pending_status(self):
to = TaskOrder(number="42")
assert to.status == Status.PENDING
@pytest.mark.skip(reason="See if still needed after implementing CLINs") @patch("atst.models.TaskOrder.end_date", new_callable=PropertyMock)
def test_active_status(self): @patch("atst.models.TaskOrder.start_date", new_callable=PropertyMock)
to = TaskOrder(number="42") @patch("atst.models.TaskOrder.is_completed", new_callable=PropertyMock)
@patch("atst.models.TaskOrder.is_signed", new_callable=PropertyMock)
def test_active_status(self, is_signed, is_completed, start_date, end_date):
# Given that I have a signed TO and today is within its start_date and end_date
today = pendulum.today().date()
to = TaskOrder()
start_date.return_value = today.subtract(days=1)
end_date.return_value = today.add(days=1)
is_signed.return_value = True
is_completed.return_value = True
# Its status should be active
assert to.status == Status.ACTIVE assert to.status == Status.ACTIVE
@pytest.mark.skip(reason="See if still needed after implementing CLINs") @patch("atst.models.TaskOrder.end_date", new_callable=PropertyMock)
def test_expired_status(self): @patch("atst.models.TaskOrder.start_date", new_callable=PropertyMock)
to = TaskOrder(number="42") @patch("atst.models.TaskOrder.is_completed", new_callable=PropertyMock)
@patch("atst.models.TaskOrder.is_signed", new_callable=PropertyMock)
def test_upcoming_status(self, is_signed, is_completed, start_date, end_date):
# Given that I have a signed TO and today is before its start_date
to = TaskOrder()
start_date.return_value = pendulum.today().add(days=1).date()
end_date.return_value = pendulum.today().add(days=2).date()
is_signed.return_value = True
is_completed.return_value = True
# Its status should be upcoming
assert to.status == Status.UPCOMING
@patch("atst.models.TaskOrder.start_date", new_callable=PropertyMock)
@patch("atst.models.TaskOrder.end_date", new_callable=PropertyMock)
@patch("atst.models.TaskOrder.is_completed", new_callable=PropertyMock)
@patch("atst.models.TaskOrder.is_signed", new_callable=PropertyMock)
def test_expired_status(self, is_signed, is_completed, end_date, start_date):
# Given that I have a signed TO and today is after its expiration date
to = TaskOrder()
end_date.return_value = pendulum.today().subtract(days=1).date()
start_date.return_value = pendulum.today().subtract(days=2).date()
is_signed.return_value = True
is_completed.return_value = True
# Its status should be expired
assert to.status == Status.EXPIRED assert to.status == Status.EXPIRED
@patch("atst.models.TaskOrder.is_completed", new_callable=PropertyMock)
@patch("atst.models.TaskOrder.is_signed", new_callable=PropertyMock)
def test_unsigned_status(self, is_signed, is_completed):
# Given that I have a TO that is completed but not signed
to = TaskOrder(signed_at=pendulum.now().subtract(days=1))
is_completed.return_value = True
is_signed.return_value = False
# Its status should be unsigned
assert to.status == Status.UNSIGNED
class TestBudget: class TestBudget:
def test_total_contract_amount(self): def test_total_contract_amount(self):

View File

@ -100,8 +100,6 @@ def test_portfolio_reports(client, user_session):
response = client.get(url_for("portfolios.reports", portfolio_id=portfolio.id)) response = client.get(url_for("portfolios.reports", portfolio_id=portfolio.id))
assert response.status_code == 200 assert response.status_code == 200
assert portfolio.name in response.data.decode() assert portfolio.name in response.data.decode()
expiration_date = task_order.end_date.strftime("%Y-%m-%d")
assert expiration_date in response.data.decode()
def test_portfolio_reports_with_mock_portfolio(client, user_session): def test_portfolio_reports_with_mock_portfolio(client, user_session):

View File

@ -18,7 +18,7 @@ def xml_translated(val):
def test_download_summary(client, user_session): def test_download_summary(client, user_session):
user = UserFactory.create() user = UserFactory.create()
portfolio = PortfolioFactory.create(owner=user) portfolio = PortfolioFactory.create(owner=user)
task_order = TaskOrderFactory.create(creator=user, portfolio=portfolio) task_order = TaskOrderFactory.create(creator=user, portfolio=portfolio, _pdf=None)
user_session(user) user_session(user)
response = client.get( response = client.get(
url_for("task_orders.download_summary", task_order_id=task_order.id) url_for("task_orders.download_summary", task_order_id=task_order.id)