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.models.clin import CLIN
from atst.models.task_order import TaskOrder
from atst.models.task_order import TaskOrder, SORT_ORDERING
from . import BaseDomainClass
@ -98,3 +98,10 @@ class TaskOrders(BaseDomainClass):
if not app.config.get("CLASSIFIED"):
section_list["funding"] = TaskOrders.UNCLASSIFIED_FUNDING
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 datetime import date
from sqlalchemy import Column, DateTime, ForeignKey, String
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.clin import JEDICLINType
from atst.utils.clock import Clock
class Status(Enum):
STARTED = "Started"
PENDING = "Pending"
DRAFT = "Draft"
ACTIVE = "Active"
UPCOMING = "Upcoming"
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):
@ -62,28 +71,41 @@ class TaskOrder(Base, mixins.TimestampsMixin):
def is_expired(self):
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
def status(self):
# TODO: fix task order -- implement correctly using CLINs
# Faked for display purposes
return Status.ACTIVE
today = Clock.today()
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
def start_date(self):
# TODO: fix task order -- reimplement using CLINs
# Faked for display purposes
return date.today()
return min((c.start_date for c in self.clins), default=self.time_created.date())
@property
def end_date(self):
# TODO: fix task order -- reimplement using CLINs
# Faked for display purposes
return date.today()
return max((c.end_date for c in self.clins), default=None)
@property
def days_to_expiration(self):
if self.end_date:
return (self.end_date - date.today()).days
return (self.end_date - Clock.today()).days
@property
def total_obligated_funds(self):

View File

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

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'"])
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
import os
import sys
from datetime import timedelta, date
from datetime import timedelta, date, timedelta
import random
from faker import Faker
@ -31,6 +31,7 @@ from tests.factories import (
random_service_branch,
random_task_order_number,
TaskOrderFactory,
CLINFactory,
)
fake = Faker()
@ -160,16 +161,28 @@ def add_members_to_portfolio(portfolio):
def add_task_orders_to_portfolio(portfolio):
# TODO: after CLINs are implemented, vary the start/end dates of TOs
create_task_order(portfolio)
create_task_order(portfolio)
create_task_order(portfolio)
today = date.today()
future = today + timedelta(days=100)
yesterday = today - timedelta(days=1)
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):
# TODO: after CLINs are implemented add them to TO
task_order = TaskOrderFactory.build(portfolio=portfolio)
db.session.add(task_order)
clins = [
CLINFactory.build(task_order=unsigned_to, start_date=today, end_date=today),
CLINFactory.build(task_order=upcoming_to, start_date=future, end_date=future),
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()

View File

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

View File

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

View File

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

View File

@ -1,19 +1,78 @@
import pytest
from datetime import date
from datetime import date, timedelta
from decimal import Decimal
from atst.domain.task_orders import TaskOrders, TaskOrderError
from atst.domain.exceptions import UnauthorizedError
from atst.domain.permission_sets import PermissionSets
from atst.domain.portfolio_roles import PortfolioRoles
from atst.domain.task_orders import TaskOrders
from atst.models.attachment import Attachment
from tests.factories import TaskOrderFactory, CLINFactory, PortfolioFactory
from tests.factories import (
TaskOrderFactory,
UserFactory,
PortfolioRoleFactory,
PortfolioFactory,
)
def test_task_order_sorting():
"""
Task orders should be listed first by status, and then by time_created.
"""
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")
@ -118,7 +177,9 @@ def test_update_adds_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 = [
{
"jedi_clin_type": "JEDI_CLIN_1",

View File

@ -272,13 +272,14 @@ class TaskOrderFactory(Base):
portfolio = factory.SubFactory(PortfolioFactory)
number = factory.LazyFunction(random_task_order_number)
creator = factory.SubFactory(UserFactory)
_pdf = factory.SubFactory(AttachmentFactory)
@classmethod
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)
for clin in with_clins:
for clin in create_clins:
CLINFactory.create(task_order=task_order, number=clin)
return task_order
@ -293,7 +294,7 @@ class CLINFactory(Base):
start_date = datetime.date.today()
end_date = factory.LazyFunction(random_future_date)
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):

View File

@ -1,40 +1,120 @@
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.clin import JEDICLINType
from atst.models.task_order import TaskOrder, Status
from tests.factories import (
CLINFactory,
random_future_date,
random_past_date,
TaskOrderFactory,
)
from tests.factories import CLINFactory, TaskOrderFactory
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:
@pytest.mark.skip(reason="Reimplement after adding CLINs")
def test_started_status(self):
@patch("atst.models.TaskOrder.is_completed", new_callable=PropertyMock)
@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()
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")
def test_pending_status(self):
to = TaskOrder(number="42")
assert to.status == Status.PENDING
assert to.status == Status.DRAFT
@pytest.mark.skip(reason="See if still needed after implementing CLINs")
def test_active_status(self):
to = TaskOrder(number="42")
@patch("atst.models.TaskOrder.end_date", new_callable=PropertyMock)
@patch("atst.models.TaskOrder.start_date", new_callable=PropertyMock)
@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
@pytest.mark.skip(reason="See if still needed after implementing CLINs")
def test_expired_status(self):
to = TaskOrder(number="42")
@patch("atst.models.TaskOrder.end_date", new_callable=PropertyMock)
@patch("atst.models.TaskOrder.start_date", new_callable=PropertyMock)
@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
@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:
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))
assert response.status_code == 200
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):

View File

@ -18,7 +18,7 @@ def xml_translated(val):
def test_download_summary(client, user_session):
user = UserFactory.create()
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)
response = client.get(
url_for("task_orders.download_summary", task_order_id=task_order.id)