Merge pull request #1239 from dod-ccpo/to-index-page-redesign_part-2

TO and Application index pages -- part 2
This commit is contained in:
leigh-mil 2019-12-12 16:32:43 -05:00 committed by GitHub
commit 2edfecdd45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 180 additions and 150 deletions

View File

@ -3,7 +3,7 @@
"files": "^.secrets.baseline$|^.*pgsslrootcert.yml$",
"lines": null
},
"generated_at": "2019-12-05T17:54:05Z",
"generated_at": "2019-12-06T21:22:07Z",
"plugins_used": [
{
"base64_limit": 4.5,
@ -161,7 +161,7 @@
"hashed_secret": "e4f14805dfd1e6af030359090c535e149e6b4207",
"is_secret": false,
"is_verified": false,
"line_number": 31,
"line_number": 41,
"type": "Hex High Entropy String"
}
],

View File

@ -64,10 +64,12 @@ class TaskOrders(BaseDomainClass):
db.session.commit()
@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))
def sort_by_status(cls, task_orders):
by_status = {status.value: [] for status in SORT_ORDERING}
for task_order in task_orders:
by_status[task_order.display_status].append(task_order)
return by_status
@classmethod

View File

@ -1,4 +1,3 @@
from datetime import timedelta
from enum import Enum
from sqlalchemy import Column, DateTime, ForeignKey, String
@ -20,12 +19,13 @@ class Status(Enum):
UNSIGNED = "Not signed"
SORT_ORDERING = {
status: order
for (order, status) in enumerate(
[Status.DRAFT, Status.ACTIVE, Status.UPCOMING, Status.EXPIRED, Status.UNSIGNED]
)
}
SORT_ORDERING = [
Status.ACTIVE,
Status.DRAFT,
Status.UPCOMING,
Status.EXPIRED,
Status.UNSIGNED,
]
class TaskOrder(Base, mixins.TimestampsMixin):
@ -131,12 +131,11 @@ class TaskOrder(Base, mixins.TimestampsMixin):
@property
def start_date(self):
return min((c.start_date for c in self.clins), default=self.time_created.date())
return min((c.start_date for c in self.clins), default=None)
@property
def end_date(self):
default_end_date = self.start_date + timedelta(days=1)
return max((c.end_date for c in self.clins), default=default_end_date)
return max((c.end_date for c in self.clins), default=None)
@property
def days_to_expiration(self):
@ -170,6 +169,11 @@ class TaskOrder(Base, mixins.TimestampsMixin):
# Faked for display purposes
return 50
@property
def invoiced_funds(self):
# TODO: implement this using reporting data from the CSP
return self.total_obligated_funds * 75 / 100
@property
def display_status(self):
return self.status.value

View File

@ -6,7 +6,6 @@ from atst.domain.portfolios import Portfolios
from atst.domain.task_orders import TaskOrders
from atst.forms.task_order import SignatureForm
from atst.models import Permissions
from atst.models.task_order import Status as TaskOrderStatus
@task_orders_bp.route("/task_orders/<task_order_id>/review")
@ -28,14 +27,6 @@ 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 = TaskOrders.sort(portfolio.task_orders)
label_colors = {
TaskOrderStatus.DRAFT: "warning",
TaskOrderStatus.ACTIVE: "success",
TaskOrderStatus.UPCOMING: "info",
TaskOrderStatus.EXPIRED: "error",
TaskOrderStatus.UNSIGNED: "purple",
}
return render_template(
"task_orders/index.html", task_orders=task_orders, label_colors=label_colors
)
task_orders = TaskOrders.sort_by_status(portfolio.task_orders)
# TODO: Get expended amount from the CSP
return render_template("task_orders/index.html", task_orders=task_orders)

View File

@ -11,4 +11,10 @@ export default {
default: false,
},
},
methods: {
collapse: function() {
this.isVisible = false
},
},
}

View File

@ -0,0 +1,16 @@
import Accordion from './accordion'
export default {
name: 'accordion-list',
components: {
Accordion,
},
methods: {
handleClick: function(e) {
e.preventDefault()
this.$children.forEach(el => el.collapse())
},
},
}

View File

@ -7,6 +7,8 @@ import Vue from 'vue/dist/vue'
import VTooltip from 'v-tooltip'
import stickybits from 'stickybits'
import Accordion from './components/accordion'
import AccordionList from './components/accordion_list'
import dodlogin from './components/dodlogin'
import optionsinput from './components/options_input'
import multicheckboxinput from './components/multi_checkbox_input'
@ -29,7 +31,6 @@ import SemiCollapsibleText from './components/semi_collapsible_text'
import ToForm from './components/forms/to_form'
import ClinFields from './components/clin_fields'
import PopDateRange from './components/pop_date_range'
import Accordion from './components/accordion'
import ToggleMenu from './components/toggle_menu'
Vue.config.productionTip = false
@ -42,6 +43,7 @@ const app = new Vue({
el: '#app-root',
components: {
Accordion,
AccordionList,
dodlogin,
toggler,
optionsinput,

View File

@ -17,6 +17,7 @@ export default {
methods: {
toggle: function(e) {
e.preventDefault()
e.stopPropagation()
this.isVisible = !this.isVisible
},
},

View File

@ -47,4 +47,12 @@
}
}
}
&-list {
max-width: $max-panel-width;
&__collapse {
cursor: pointer;
}
}
}

View File

@ -127,21 +127,6 @@
width: 100%;
}
.label {
&--pending,
&--started {
background-color: $color-gold;
}
&--active {
background-color: $color-green;
}
&--expired {
background-color: $color-red;
}
}
.task-order-document-link {
&__icon {
padding-top: 0.5rem;

View File

@ -1,4 +1,5 @@
{% from "components/accordion.html" import Accordion %}
{% from "components/accordion_list.html" import AccordionList %}
{% from "components/empty_state.html" import EmptyState %}
{% from "components/sticky_cta.html" import StickyCTA %}
{% from "components/icon.html" import Icon %}
@ -32,7 +33,7 @@
) }}
{% else %}
<div class="usa-accordion">
{% call AccordionList() %}
{% for application in portfolio.applications|sort(attribute='name') %}
{% set section_name = "application-{}".format(application.id) %}
{% set title = "Environments ({})".format(application.environments|length) %}
@ -76,7 +77,7 @@
{% endcall %}
</div>
{% endfor %}
</div>
{% endcall %}
{% endif %}
</div>

View File

@ -0,0 +1,11 @@
{% macro AccordionList() %}
<accordion-list inline-template>
<div class="accordion-list usa-accordion">
<div class="action-group">
<a v-on:click="handleClick($event)" class="accordion-list__collapse">Collapse All</a>
</div>
<!-- caller iterates over accordion vue components or Accordion jinja macros -->
{{ caller() }}
</div>
</accordion-list>
{% endmacro %}

View File

@ -1,4 +1,5 @@
{% from "components/accordion.html" import Accordion %}
{% from "components/accordion_list.html" import AccordionList %}
{% from "components/empty_state.html" import EmptyState %}
{% from "components/icon.html" import Icon %}
{% from "components/sticky_cta.html" import StickyCTA %}
@ -13,36 +14,44 @@
{% macro TaskOrderList(task_orders, status) %}
{% set status = "All Task Orders" %}
<div class="accordion usa-accordion">
<div class="accordion">
{% call Accordion(title=status, id=status, heading_tag="h4") %}
{% for task_order in task_orders %}
{% set to_number %}
{% if task_order.number != "" %}
Task Order #{{ task_order.number }}
{% else %}
New Task Order
{% endif %}
{% endset %}
<div class="accordion__content--list-item">
<h4><a href="{{ url_for('task_orders.review_task_order', task_order_id=task_order.id) }}">Task Order #{{ task_order.number }} {{ Icon("caret_right", classes="icon--tiny icon--primary" ) }}</a></h4>
<div class="row">
<div class="col col--grow">
<h5>
Current Period of Performance
</h5>
<p>
{{ task_order.start_date | formattedDate(formatter="%b %d, %Y") }}
-
{{ task_order.end_date | formattedDate(formatter="%b %d, %Y") }}
</p>
<h4><a href="{{ url_for('task_orders.review_task_order', task_order_id=task_order.id) }}">{{ to_number }} {{ Icon("caret_right", classes="icon--tiny icon--primary" ) }}</a></h4>
{% if status != 'Expired' -%}
<div class="row">
<div class="col col--grow">
<h5>
Current Period of Performance
</h5>
<p>
{{ task_order.start_date | formattedDate(formatter="%b %d, %Y") }}
-
{{ task_order.end_date | formattedDate(formatter="%b %d, %Y") }}
</p>
</div>
<div class="col col--grow">
<h5>Total Value</h5>
<p>{{ task_order.total_contract_amount | dollars }}</p>
</div>
<div class="col col--grow">
<h5>Total Obligated</h5>
<p>{{ task_order.total_obligated_funds | dollars }}</p>
</div>
<div class="col col--grow">
<h5>Total Expended</h5>
<p>{{ task_order.invoiced_funds | dollars }}</p>
</div>
</div>
<div class="col col--grow">
<h5>Total Value</h5>
<p>{{ task_order.total_contract_amount | dollars }}</p>
</div>
<div class="col col--grow">
<h5>Total Obligated</h5>
<p>{{ task_order.total_obligated_funds | dollars }}</p>
</div>
<div class="col col--grow">
<h5>Total Expended</h5>
<p>$0</p>
</div>
</div>
{%- endif %}
</div>
{% endfor %}
{% endcall %}
@ -63,7 +72,11 @@
<div class="portfolio-funding">
{% if task_orders %}
{{ TaskOrderList(task_orders) }}
{% call AccordionList() %}
{% for status, to_list in task_orders.items() %}
{{ TaskOrderList(to_list, status) }}
{% endfor %}
{% endcall %}
{% else %}
{{ EmptyState(
header="task_orders.empty_state.header"|translate,

View File

@ -3,78 +3,11 @@ from datetime import date, timedelta
from decimal import Decimal
from atst.domain.task_orders import TaskOrders
from atst.models import Attachment, TaskOrder
from atst.models import Attachment
from atst.models.task_order import TaskOrder, SORT_ORDERING, Status
from tests.factories import TaskOrderFactory, CLINFactory, 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
def test_create_adds_clins():
portfolio = PortfolioFactory.create()
clins = [
@ -177,3 +110,47 @@ def test_delete_task_order_with_clins(session):
assert not session.query(
session.query(TaskOrder).filter_by(id=task_order.id).exists()
).scalar()
def test_task_order_sort_by_status():
today = date.today()
yesterday = today - timedelta(days=1)
future = today + timedelta(days=100)
initial_to_list = [
# 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)],
),
# Upcoming
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)],
),
# Unsigned
TaskOrderFactory.create(
clins=[CLINFactory.create(start_date=today, end_date=today)]
),
]
sorted_by_status = TaskOrders.sort_by_status(initial_to_list)
assert len(sorted_by_status["Draft"]) == 3
assert len(sorted_by_status["Active"]) == 1
assert len(sorted_by_status["Upcoming"]) == 1
assert len(sorted_by_status["Expired"]) == 2
assert len(sorted_by_status["Not signed"]) == 1
assert list(sorted_by_status.keys()) == [status.value for status in SORT_ORDERING]

View File

@ -29,8 +29,10 @@ def task_order():
user = UserFactory.create()
portfolio = PortfolioFactory.create(owner=user)
attachment = Attachment(filename="sample_attachment", object_name="sample")
task_order = TaskOrderFactory.create(portfolio=portfolio)
CLINFactory.create(task_order=task_order)
return TaskOrderFactory.create(portfolio=portfolio)
return task_order
def test_review_task_order_not_draft(client, user_session, task_order):

View File

@ -19,6 +19,16 @@ def build_pdf_form_data(filename="sample.pdf", object_name=None):
def task_order():
user = UserFactory.create()
portfolio = PortfolioFactory.create(owner=user)
task_order = TaskOrderFactory.create(portfolio=portfolio)
CLINFactory.create(task_order=task_order)
return task_order
@pytest.fixture
def incomplete_to():
user = UserFactory.create()
portfolio = PortfolioFactory.create(owner=user)
return TaskOrderFactory.create(portfolio=portfolio)
@ -234,7 +244,7 @@ def test_task_orders_submit_form_step_three_add_clins_existing_to(
},
]
TaskOrders.create_clins(task_order.id, clin_list)
assert len(task_order.clins) == 2
assert len(task_order.clins) == 3
user_session(task_order.portfolio.owner)
form_data = {
@ -267,11 +277,11 @@ def test_task_orders_form_step_four_review(client, user_session, completed_task_
def test_task_orders_form_step_four_review_incomplete_to(
client, user_session, task_order
client, user_session, incomplete_to
):
user_session(task_order.portfolio.owner)
user_session(incomplete_to.portfolio.owner)
response = client.get(
url_for("task_orders.form_step_four_review", task_order_id=task_order.id)
url_for("task_orders.form_step_four_review", task_order_id=incomplete_to.id)
)
assert response.status_code == 404
@ -290,12 +300,13 @@ def test_task_orders_form_step_five_confirm_signature(
def test_task_orders_form_step_five_confirm_signature_incomplete_to(
client, user_session, task_order
client, user_session, incomplete_to
):
user_session(task_order.portfolio.owner)
user_session(incomplete_to.portfolio.owner)
response = client.get(
url_for(
"task_orders.form_step_five_confirm_signature", task_order_id=task_order.id
"task_orders.form_step_five_confirm_signature",
task_order_id=incomplete_to.id,
)
)
assert response.status_code == 404