diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index b3206b35..a433965e 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -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 diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 0e2d5ba2..5088ce70 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -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): diff --git a/atst/routes/task_orders/index.py b/atst/routes/task_orders/index.py index d91db98f..47a0ff56 100644 --- a/atst/routes/task_orders/index.py +++ b/atst/routes/task_orders/index.py @@ -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/") @@ -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, ) diff --git a/atst/utils/clock.py b/atst/utils/clock.py new file mode 100644 index 00000000..1ac4455e --- /dev/null +++ b/atst/utils/clock.py @@ -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) diff --git a/script/example_fetch_from_eda.py b/script/example_fetch_from_eda.py index 4cd88664..dc297347 100644 --- a/script/example_fetch_from_eda.py +++ b/script/example_fetch_from_eda.py @@ -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, ) diff --git a/script/seed_sample.py b/script/seed_sample.py index 6c9d3f34..8fced973 100644 --- a/script/seed_sample.py +++ b/script/seed_sample.py @@ -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() diff --git a/styles/elements/_labels.scss b/styles/elements/_labels.scss index d28ab484..15758fe2 100644 --- a/styles/elements/_labels.scss +++ b/styles/elements/_labels.scss @@ -33,4 +33,8 @@ &.label--success { background-color: $color-green; } + + &.label--purple { + background-color: $color-purple; + } } diff --git a/styles/sections/_task_order.scss b/styles/sections/_task_order.scss index 7a0186b4..dc97dfc1 100644 --- a/styles/sections/_task_order.scss +++ b/styles/sections/_task_order.scss @@ -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 { diff --git a/templates/portfolios/task_orders/index.html b/templates/portfolios/task_orders/index.html index 2df23b45..0e4c52b8 100644 --- a/templates/portfolios/task_orders/index.html +++ b/templates/portfolios/task_orders/index.html @@ -45,7 +45,7 @@ {% for task_order in task_orders %}
- {{ task_order.display_status }} + {{ task_order.display_status }} {{ TaskOrderDate(task_order) }} @@ -70,7 +70,9 @@
- {% if not active_task_orders and not pending_task_orders %} + {% if task_orders %} + {{ TaskOrderList(task_orders) }} + {% else %} {{ EmptyState( 'This portfolio doesn’t 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 %}
{% endblock %} diff --git a/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index 6a337505..4a62844c 100644 --- a/tests/domain/test_task_orders.py +++ b/tests/domain/test_task_orders.py @@ -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", diff --git a/tests/factories.py b/tests/factories.py index 05b8606f..3bbee856 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -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): diff --git a/tests/models/test_task_order.py b/tests/models/test_task_order.py index d6ae9e1c..8b02effb 100644 --- a/tests/models/test_task_order.py +++ b/tests/models/test_task_order.py @@ -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): diff --git a/tests/routes/portfolios/test_index.py b/tests/routes/portfolios/test_index.py index 368aa02e..948c4301 100644 --- a/tests/routes/portfolios/test_index.py +++ b/tests/routes/portfolios/test_index.py @@ -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): diff --git a/tests/routes/task_orders/test_downloads.py b/tests/routes/task_orders/test_downloads.py index 965d8d6d..173a0f27 100644 --- a/tests/routes/task_orders/test_downloads.py +++ b/tests/routes/task_orders/test_downloads.py @@ -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)