diff --git a/alembic/versions/f03333c42bdb_add_total_amount_to_clins.py b/alembic/versions/f03333c42bdb_add_total_amount_to_clins.py new file mode 100644 index 00000000..8201bb61 --- /dev/null +++ b/alembic/versions/f03333c42bdb_add_total_amount_to_clins.py @@ -0,0 +1,29 @@ +"""Add total amount to CLINs + +Revision ID: f03333c42bdb +Revises: 4a3122ffe898 +Create Date: 2019-09-04 15:35:39.446486 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f03333c42bdb' # pragma: allowlist secret +down_revision = '30ea1cb20807' # pragma: allowlist secret +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('clins', sa.Column('total_amount', sa.Numeric(scale=2), nullable=True)) + op.execute("UPDATE clins SET total_amount = 0") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('clins', 'total_amount') + # ### end Alembic commands ### diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 2c27977c..93521843 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -58,6 +58,7 @@ class TaskOrders(BaseDomainClass): number=clin_data["number"], start_date=clin_data["start_date"], end_date=clin_data["end_date"], + total_amount=clin_data["total_amount"], obligated_amount=clin_data["obligated_amount"], jedi_clin_type=clin_data["jedi_clin_type"], ) diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 09a8fe8e..ddf029f8 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -7,9 +7,10 @@ from wtforms.fields import ( HiddenField, ) from wtforms.fields.html5 import DateField -from wtforms.validators import Required, Optional, Length +from wtforms.validators import Required, Optional, Length, NumberRange, ValidationError from flask_wtf import FlaskForm from datetime import datetime +from numbers import Number from .data import JEDI_CLIN_TYPES from .fields import SelectField @@ -17,6 +18,8 @@ from .forms import BaseForm from atst.utils.localization import translate from flask import current_app as app +MAX_CLIN_AMOUNT = 1000000000 + def coerce_enum(enum_inst): if getattr(enum_inst, "value", None): @@ -25,6 +28,17 @@ def coerce_enum(enum_inst): return enum_inst +def validate_funding(form, field): + if ( + isinstance(form.total_amount.data, Number) + and isinstance(field.data, Number) + and form.total_amount.data < field.data + ): + raise ValidationError( + translate("forms.task_order.clin_funding_errors.obligated_amount_error") + ) + + class CLINForm(FlaskForm): jedi_clin_type = SelectField( translate("task_orders.form.clin_type_label"), @@ -47,9 +61,26 @@ class CLINForm(FlaskForm): format="%m/%d/%Y", validators=[Optional()], ) + total_amount = DecimalField( + label=translate("task_orders.form.total_funds_label"), + validators=[ + NumberRange( + 0, + MAX_CLIN_AMOUNT, + translate("forms.task_order.clin_funding_errors.funding_range_error"), + ) + ], + ) obligated_amount = DecimalField( label=translate("task_orders.form.obligated_funds_label"), - validators=[Optional()], + validators=[ + validate_funding, + NumberRange( + 0, + MAX_CLIN_AMOUNT, + translate("forms.task_order.clin_funding_errors.funding_range_error"), + ), + ], ) def validate(self, *args, **kwargs): diff --git a/atst/models/clin.py b/atst/models/clin.py index da2d1d02..6107a1f3 100644 --- a/atst/models/clin.py +++ b/atst/models/clin.py @@ -23,6 +23,7 @@ class CLIN(Base, mixins.TimestampsMixin): number = Column(String, nullable=True) start_date = Column(Date, nullable=True) end_date = Column(Date, nullable=True) + total_amount = Column(Numeric(scale=2), nullable=True) obligated_amount = Column(Numeric(scale=2), nullable=True) jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=True) @@ -46,6 +47,7 @@ class CLIN(Base, mixins.TimestampsMixin): self.number, self.start_date, self.end_date, + self.total_amount, self.obligated_amount, self.jedi_clin_type, ] diff --git a/atst/models/task_order.py b/atst/models/task_order.py index bd592bea..3fe3d3e3 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -6,7 +6,6 @@ from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship from atst.models import Attachment, Base, mixins, types -from atst.models.clin import JEDICLINType from atst.utils.clock import Clock @@ -148,10 +147,7 @@ class TaskOrder(Base, mixins.TimestampsMixin): def total_obligated_funds(self): total = 0 for clin in self.clins: - if clin.obligated_amount is not None and clin.jedi_clin_type in [ - JEDICLINType.JEDI_CLIN_1, - JEDICLINType.JEDI_CLIN_3, - ]: + if clin.obligated_amount is not None: total += clin.obligated_amount return total @@ -159,8 +155,8 @@ class TaskOrder(Base, mixins.TimestampsMixin): def total_contract_amount(self): total = 0 for clin in self.clins: - if clin.obligated_amount is not None: - total += clin.obligated_amount + if clin.total_amount is not None: + total += clin.total_amount return total @property diff --git a/js/components/clin_dollar_amount.js b/js/components/clin_dollar_amount.js new file mode 100644 index 00000000..ccebed68 --- /dev/null +++ b/js/components/clin_dollar_amount.js @@ -0,0 +1,39 @@ +import inputValidations from '../lib/input_validations' +import TextInputMixin from '../mixins/text_input_mixin' + +export default { + name: 'clindollaramount', + + mixins: [TextInputMixin], + + props: { + fundingValid: Boolean, + }, + + computed: { + rawValue: function() { + return this._rawValue(this.value) + }, + showFundingError: function() { + return this.showError || !this.fundingValid + }, + showFundingValid: function() { + return this.showValid && this.fundingValid + }, + }, + watch: { + fundingValid: function(oldVal, newVal) { + this._checkIfValid({ value: this.value, invalidate: true }) + }, + }, + + methods: { + _validate: function(value) { + const rawValue = this._rawValue(value) + if (rawValue < 0 || rawValue > 1000000000 || !this.fundingValid) { + return false + } + return inputValidations[this.validation].match.test(rawValue) + }, + }, +} diff --git a/js/components/clin_fields.js b/js/components/clin_fields.js index eb958780..fb42b2a7 100644 --- a/js/components/clin_fields.js +++ b/js/components/clin_fields.js @@ -6,7 +6,10 @@ import { emitEvent } from '../lib/emitters' import Modal from '../mixins/modal' import optionsinput from './options_input' import textinput from './text_input' +import clindollaramount from './clin_dollar_amount' +const TOTAL_AMOUNT = 'total_amount' +const OBLIGATED_AMOUNT = 'obligated_amount' const START_DATE = 'start_date' const END_DATE = 'end_date' const POP = 'period_of_performance' @@ -19,12 +22,21 @@ export default { DateSelector, optionsinput, textinput, + clindollaramount, }, mixins: [Modal], props: { initialClinIndex: Number, + initialTotal: { + type: Number, + default: 0, + }, + initialObligated: { + type: Number, + default: 0, + }, initialStartDate: { type: String, default: null, @@ -54,6 +66,10 @@ export default { const end = !!this.initialEndDate ? new Date(this.initialEndDate) : undefined + const fundingValidation = + this.initialObligated && this.initialTotal + ? this.initialObligated <= this.initialTotal + : true const popValidation = !this.initialStartDate ? false : start < end const clinNumber = !!this.initialClinNumber ? this.initialClinNumber @@ -63,6 +79,7 @@ export default { return { clinIndex: this.initialClinIndex, + clinNumber: clinNumber, startDate: start, endDate: end, popValid: popValidation, @@ -93,14 +110,23 @@ export default { )}.`, }, ], + totalAmount: this.initialTotal || 0, + obligatedAmount: this.initialObligated || 0, + fundingValid: fundingValidation, } }, mounted: function() { this.$root.$on('field-change', this.handleFieldChange) + this.validateFunding() }, created: function() { + emitEvent('clin-change', this, { + id: this._uid, + obligatedAmount: this.initialObligated, + totalAmount: this.initialTotal, + }) emitEvent('field-mount', this, { optional: false, name: 'clins-' + this.clinIndex + '-' + POP, @@ -109,6 +135,14 @@ export default { }, methods: { + clinChangeEvent: function() { + emitEvent('clin-change', this, { + id: this._uid, + obligatedAmount: this.initialObligated, + totalAmount: this.initialTotal, + }) + }, + checkPopValid: function() { return ( this.popDateOrder() && @@ -153,9 +187,25 @@ export default { return true }, + checkFundingValid: function() { + return this.obligatedAmount <= this.totalAmount + }, + + validateFunding: function() { + if (this.totalAmount && this.obligatedAmount) { + this.fundingValid = this.checkFundingValid() + } + }, + handleFieldChange: function(event) { if (this._uid === event.parent_uid) { - if (event.name.includes(START_DATE)) { + if (event.name.includes(TOTAL_AMOUNT)) { + this.totalAmount = parseFloat(event.value) + this.validateFunding() + } else if (event.name.includes(OBLIGATED_AMOUNT)) { + this.obligatedAmount = parseFloat(event.value) + this.validateFunding() + } else if (event.name.includes(START_DATE)) { if (!!event.value) this.startDate = new Date(event.value) if (!!event.valid) this.startDateValid = event.valid this.validatePop() @@ -186,6 +236,20 @@ export default { return `CLIN` } }, + percentObligated: function() { + const percentage = (this.obligatedAmount / this.totalAmount) * 100 + if (!!percentage) { + if (percentage > 0 && percentage < 1) { + return '<1%' + } else if (percentage > 99 && percentage < 100) { + return '>99%' + } else { + return `${percentage.toFixed(0)}%` + } + } else { + return '0%' + } + }, removeModalId: function() { return `remove-clin-${this.clinIndex}` diff --git a/js/components/text_input.js b/js/components/text_input.js index 92c8c7e4..9f91c832 100644 --- a/js/components/text_input.js +++ b/js/components/text_input.js @@ -1,174 +1,6 @@ -import MaskedInput, { conformToMask } from 'vue-text-mask' -import inputValidations from '../lib/input_validations' -import { formatDollars } from '../lib/dollars' -import { emitEvent } from '../lib/emitters' +import TextInputMixin from '../mixins/text_input_mixin' export default { name: 'textinput', - - components: { - MaskedInput, - }, - - props: { - name: String, - validation: { - type: String, - default: () => 'anything', - }, - initialValue: { - type: String, - default: () => '', - }, - initialErrors: { - type: Array, - default: () => [], - }, - paragraph: String, - noMaxWidth: String, - optional: Boolean, - watch: { - type: Boolean, - default: false, - }, - }, - - data: function() { - return { - showError: (this.initialErrors && this.initialErrors.length) || false, - showValid: false, - mask: inputValidations[this.validation].mask, - pipe: inputValidations[this.validation].pipe || undefined, - keepCharPositions: - inputValidations[this.validation].keepCharPositions || false, - validationError: - this.initialErrors.join(' ') || - inputValidations[this.validation].validationError, - value: this.initialValue, - modified: false, - } - }, - - computed: { - rawValue: function() { - return this._rawValue(this.value) - }, - }, - - mounted: function() { - if (this.value) { - this._checkIfValid({ - value: this.value, - invalidate: true, - showValidationIcon: false, - }) - - if (this.mask && this.validation !== 'email') { - const mask = - typeof this.mask.mask !== 'function' - ? this.mask - : mask.mask(this.value).filter(val => val !== '[]') - - this.value = conformToMask(this.value, mask).conformedValue - } - } - }, - - created: function() { - emitEvent('field-mount', this, { - optional: this.optional, - name: this.name, - valid: this._isValid(this.value), - }) - }, - - methods: { - // When user types a character - onInput: function(e) { - // When we use the native textarea element, we receive an event object - // When we use the masked-input component, we receive the value directly - const value = typeof e === 'object' ? e.target.value : e - this.value = value - this.modified = true - this._checkIfValid({ value }) - }, - - // When field is blurred (un-focused) - onChange: function(e) { - // Only invalidate the field when it blurs - this._checkIfValid({ value: e.target.value, invalidate: true }) - }, - - onBlur: function(e) { - if (!(this.optional && e.target.value === '')) { - this._checkIfValid({ value: e.target.value.trim(), invalidate: true }) - } else if (this.modified && !this.optional) { - this._checkIfValid({ value: e.target.value.trim(), invalidate: true }) - } - this.value = e.target.value.trim() - - if (this.validation === 'dollars') { - let value = Number.isNaN(e.target.value) ? '0' : e.target.value - this.value = formatDollars(this._rawValue(value)) - } - }, - - // - _checkIfValid: function({ - value, - invalidate = false, - showValidationIcon = true, - }) { - const valid = this._isValid(value) - if (this.modified) { - this.validationError = inputValidations[this.validation].validationError - } - - // Show error messages or not - if (valid) { - this.showError = false - } else if (invalidate) { - this.showError = true - } - - if (showValidationIcon) { - this.showValid = this.value != '' && valid - } - - // Emit a change event - emitEvent('field-change', this, { - value: this._rawValue(value), - valid: this._isValid(value), - name: this.name, - watch: this.watch, - }) - }, - - _rawValue: function(value) { - return inputValidations[this.validation].unmask.reduce( - (currentValue, character) => { - return currentValue.split(character).join('') - }, - value - ) - }, - - _validate: function(value) { - return inputValidations[this.validation].match.test(this._rawValue(value)) - }, - - _isValid: function(value) { - let valid = this._validate(value) - - if (!this.modified && this.initialErrors && this.initialErrors.length) { - valid = false - } else if (this.optional && value === '') { - valid = true - } else if (!this.optional && value === '') { - valid = false - } - - return valid - }, - }, + mixins: [TextInputMixin], } diff --git a/js/lib/input_validations.js b/js/lib/input_validations.js index a873b3c0..af74f211 100644 --- a/js/lib/input_validations.js +++ b/js/lib/input_validations.js @@ -34,6 +34,13 @@ export default { unmask: ['$', ','], validationError: 'Please enter a dollar amount', }, + clinDollars: { + mask: createNumberMask({ prefix: '$', allowDecimal: true }), + match: /^-?\d+\.?\d*$/, + unmask: ['$', ','], + validationError: + 'Please enter a dollar amount between $0.00 and $1,000,000,000.00', + }, email: { mask: emailMask, match: /^.+@[^.].*\.[a-zA-Z]{2,10}$/, diff --git a/js/mixins/text_input_mixin.js b/js/mixins/text_input_mixin.js new file mode 100644 index 00000000..1e728ee7 --- /dev/null +++ b/js/mixins/text_input_mixin.js @@ -0,0 +1,173 @@ +import MaskedInput, { conformToMask } from 'vue-text-mask' +import inputValidations from '../lib/input_validations' +import { formatDollars } from '../lib/dollars' +import { emitEvent } from '../lib/emitters' + +export default { + name: 'textinput', + + components: { + MaskedInput, + }, + + props: { + name: String, + validation: { + type: String, + default: () => 'anything', + }, + initialValue: { + type: String, + default: () => '', + }, + initialErrors: { + type: Array, + default: () => [], + }, + paragraph: String, + noMaxWidth: String, + optional: Boolean, + watch: { + type: Boolean, + default: false, + }, + }, + + data: function() { + return { + showError: (this.initialErrors && this.initialErrors.length) || false, + showValid: false, + mask: inputValidations[this.validation].mask, + pipe: inputValidations[this.validation].pipe || undefined, + keepCharPositions: + inputValidations[this.validation].keepCharPositions || false, + validationError: + this.initialErrors.join(' ') || + inputValidations[this.validation].validationError, + value: this.initialValue, + modified: false, + } + }, + + computed: { + rawValue: function() { + return this._rawValue(this.value) + }, + }, + + mounted: function() { + if (this.value) { + this._checkIfValid({ + value: this.value, + invalidate: true, + showValidationIcon: false, + }) + + if (this.mask && this.validation !== 'email') { + const mask = + typeof this.mask.mask !== 'function' + ? this.mask + : mask.mask(this.value).filter(val => val !== '[]') + + this.value = conformToMask(this.value, mask).conformedValue + } + } + }, + + created: function() { + emitEvent('field-mount', this, { + optional: this.optional, + name: this.name, + valid: this._isValid(this.value), + }) + }, + + methods: { + // When user types a character + onInput: function(e) { + // When we use the native textarea element, we receive an event object + // When we use the masked-input component, we receive the value directly + const value = typeof e === 'object' ? e.target.value : e + this.value = value + this.modified = true + this._checkIfValid({ value }) + }, + + // When field is blurred (un-focused) + onChange: function(e) { + // Only invalidate the field when it blurs + this._checkIfValid({ value: e.target.value, invalidate: true }) + }, + + onBlur: function(e) { + if (!(this.optional && e.target.value === '')) { + this._checkIfValid({ value: e.target.value.trim(), invalidate: true }) + } else if (this.modified && !this.optional) { + this._checkIfValid({ value: e.target.value.trim(), invalidate: true }) + } + this.value = e.target.value.trim() + + if (this.validation === 'dollars') { + let value = Number.isNaN(e.target.value) ? '0' : e.target.value + this.value = formatDollars(this._rawValue(value)) + } + }, + + // + _checkIfValid: function({ + value, + invalidate = false, + showValidationIcon = true, + }) { + const valid = this._isValid(value) + if (this.modified) { + this.validationError = inputValidations[this.validation].validationError + } + + // Show error messages or not + if (valid) { + this.showError = false + } else if (invalidate) { + this.showError = true + } + + if (showValidationIcon) { + this.showValid = this.value != '' && valid + } + + // Emit a change event + emitEvent('field-change', this, { + value: this._rawValue(value), + valid: this._isValid(value), + name: this.name, + watch: this.watch, + }) + }, + + _rawValue: function(value) { + return inputValidations[this.validation].unmask.reduce( + (currentValue, character) => { + return currentValue.split(character).join('') + }, + value + ) + }, + + _validate: function(value) { + return inputValidations[this.validation].match.test(this._rawValue(value)) + }, + + _isValid: function(value) { + let valid = this._validate(value) + if (!this.modified && this.initialErrors && this.initialErrors.length) { + valid = false + } else if (this.optional && value === '') { + valid = true + } else if (!this.optional && value === '') { + valid = false + } + + return valid + }, + }, +} diff --git a/templates/components/clin_dollar_amount.html b/templates/components/clin_dollar_amount.html new file mode 100644 index 00000000..dd8468dc --- /dev/null +++ b/templates/components/clin_dollar_amount.html @@ -0,0 +1,70 @@ +{% from 'components/icon.html' import Icon %} + +{% macro CLINDollarAmount(type, field=None, funding_validation=False) -%} +
+
+ +
+ {% if field %} +
+
+
+
+ {%- endmacro %} \ No newline at end of file diff --git a/templates/fragments/task_order_review.html b/templates/fragments/task_order_review.html index bf76ba41..3956da3d 100644 --- a/templates/fragments/task_order_review.html +++ b/templates/fragments/task_order_review.html @@ -43,7 +43,7 @@ {{ "task_orders.review.clins.type" | translate }} {{ "task_orders.review.clins.idiq_clin_description" | translate }} {{ "task_orders.review.clins.pop" | translate }} - {{ "task_orders.review.clins.amount" | translate }} + {{ "task_orders.review.clins.total_amount" | translate }} {{ "task_orders.review.clins.obligated" | translate }} @@ -58,7 +58,7 @@ {{ clin.start_date | formattedDate }} - {{ clin.end_date | formattedDate }} {# TODO: Swap in total CLIN amount #} - $123,456,789.00 + {{ clin.total_amount | dollars }} {{ clin.obligated_amount | dollars }} {% endfor %} diff --git a/templates/portfolios/task_orders/index.html b/templates/portfolios/task_orders/index.html index 5371c95d..aca7b052 100644 --- a/templates/portfolios/task_orders/index.html +++ b/templates/portfolios/task_orders/index.html @@ -81,6 +81,9 @@

Task Order #{{ task_order.number }}

+
+ Total amount: {{ task_order.total_contract_amount | dollars }} +
Obligated amount: {{ task_order.total_obligated_funds | dollars }}
diff --git a/templates/task_orders/step_3.html b/templates/task_orders/step_3.html index bff66003..87ce5daa 100644 --- a/templates/task_orders/step_3.html +++ b/templates/task_orders/step_3.html @@ -5,6 +5,7 @@ {% from 'components/icon.html' import Icon %} {% from 'components/options_input.html' import OptionsInput %} {% from 'components/text_input.html' import TextInput %} +{% from "components/clin_dollar_amount.html" import CLINDollarAmount %} {% from 'task_orders/form_header.html' import TOFormStepHeader %} {% set action = url_for("task_orders.submit_form_step_three_add_clins", task_order_id=task_order_id) %} @@ -16,6 +17,8 @@ + {% if fields %} -
-
- {{ TextInput(fields.obligated_amount, validation='dollars', watch=True, optional=False) }} -
-
+ {{ CLINDollarAmount("total", field=fields.total_amount) }} + {{ CLINDollarAmount("obligated", field=fields.obligated_amount, funding_validation=True) }} {% else %} -
-
- -
- - - - - - - - - -
-
-
-
+ {{ CLINDollarAmount("total") }} + {{ CLINDollarAmount("obligated", funding_validation=True) }} {% endif %} + +
Percent Obligated
+

+
diff --git a/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index e718d58f..31231845 100644 --- a/tests/domain/test_task_orders.py +++ b/tests/domain/test_task_orders.py @@ -84,6 +84,7 @@ def test_create_adds_clins(): "start_date": date(2020, 1, 1), "end_date": date(2021, 1, 1), "obligated_amount": Decimal("5000"), + "total_amount": Decimal("10000"), }, { "jedi_clin_type": "JEDI_CLIN_1", @@ -91,6 +92,7 @@ def test_create_adds_clins(): "start_date": date(2020, 1, 1), "end_date": date(2021, 1, 1), "obligated_amount": Decimal("5000"), + "total_amount": Decimal("10000"), }, ] task_order = TaskOrders.create( @@ -113,6 +115,7 @@ def test_update_adds_clins(): "start_date": date(2020, 1, 1), "end_date": date(2021, 1, 1), "obligated_amount": Decimal("5000"), + "total_amount": Decimal("10000"), }, { "jedi_clin_type": "JEDI_CLIN_1", @@ -120,6 +123,7 @@ def test_update_adds_clins(): "start_date": date(2020, 1, 1), "end_date": date(2021, 1, 1), "obligated_amount": Decimal("5000"), + "total_amount": Decimal("10000"), }, ] task_order = TaskOrders.create( @@ -144,6 +148,7 @@ def test_update_does_not_duplicate_clins(): "start_date": date(2020, 1, 1), "end_date": date(2021, 1, 1), "obligated_amount": Decimal("5000"), + "total_amount": Decimal("10000"), }, { "jedi_clin_type": "JEDI_CLIN_1", @@ -151,6 +156,7 @@ def test_update_does_not_duplicate_clins(): "start_date": date(2020, 1, 1), "end_date": date(2021, 1, 1), "obligated_amount": Decimal("5000"), + "total_amount": Decimal("10000"), }, ] task_order = TaskOrders.update( diff --git a/tests/factories.py b/tests/factories.py index 7dd4a2f6..d2bc8155 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -291,7 +291,8 @@ class CLINFactory(Base): number = factory.LazyFunction(random_task_order_number) start_date = datetime.date.today() end_date = factory.LazyFunction(random_future_date) - obligated_amount = factory.LazyFunction(lambda *args: random.randint(100, 999999)) + total_amount = factory.LazyFunction(lambda *args: random.randint(50000, 999999)) + obligated_amount = factory.LazyFunction(lambda *args: random.randint(100, 50000)) jedi_clin_type = factory.LazyFunction( lambda *args: random.choice(list(clin.JEDICLINType)) ) diff --git a/tests/forms/test_task_order.py b/tests/forms/test_task_order.py index 506312fe..9fb12bcf 100644 --- a/tests/forms/test_task_order.py +++ b/tests/forms/test_task_order.py @@ -72,3 +72,34 @@ def test_clin_form_pop_dates_within_contract_dates(): ) valid_clin_form = CLINForm(obj=valid_clin) assert valid_clin_form.validate() + + +def test_clin_form_obligated_greater_than_total(): + invalid_clin = factories.CLINFactory.create( + total_amount=0, + obligated_amount=1, + start_date=datetime.date(2019, 9, 15), + end_date=datetime.date(2020, 9, 14), + ) + invalid_clin_form = CLINForm(obj=invalid_clin) + assert not invalid_clin_form.validate() + assert ( + translate("forms.task_order.clin_funding_errors.obligated_amount_error") + ) in invalid_clin_form.obligated_amount.errors + + +def test_clin_form_dollar_amounts_out_of_range(): + invalid_clin = factories.CLINFactory.create( + total_amount=-1, + obligated_amount=1000000001, + start_date=datetime.date(2019, 9, 15), + end_date=datetime.date(2020, 9, 14), + ) + invalid_clin_form = CLINForm(obj=invalid_clin) + assert not invalid_clin_form.validate() + assert ( + translate("forms.task_order.clin_funding_errors.funding_range_error") + ) in invalid_clin_form.total_amount.errors + assert ( + translate("forms.task_order.clin_funding_errors.funding_range_error") + ) in invalid_clin_form.obligated_amount.errors diff --git a/tests/models/test_task_order.py b/tests/models/test_task_order.py index 19a2a6af..cb1ca7de 100644 --- a/tests/models/test_task_order.py +++ b/tests/models/test_task_order.py @@ -152,19 +152,23 @@ class TestBudget: assert ( to.total_contract_amount - == clin1.obligated_amount + clin2.obligated_amount + clin3.obligated_amount + == clin1.total_amount + clin2.total_amount + clin3.total_amount ) def test_total_obligated_funds(self): to = TaskOrder() - clin4 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_4) assert to.total_obligated_funds == 0 clin1 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_1) clin2 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_2) clin3 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_3) + clin4 = CLINFactory(task_order=to, jedi_clin_type=JEDICLINType.JEDI_CLIN_4) assert ( - to.total_obligated_funds == clin1.obligated_amount + clin3.obligated_amount + to.total_obligated_funds + == clin1.obligated_amount + + clin2.obligated_amount + + clin3.obligated_amount + + clin4.obligated_amount ) diff --git a/tests/routes/task_orders/test_new.py b/tests/routes/task_orders/test_new.py index 4d64ed38..947269d3 100644 --- a/tests/routes/task_orders/test_new.py +++ b/tests/routes/task_orders/test_new.py @@ -194,11 +194,13 @@ def test_task_orders_submit_form_step_three_add_clins(client, user_session, task "clins-0-start_date": "01/01/2020", "clins-0-end_date": "01/01/2021", "clins-0-obligated_amount": "5000", + "clins-0-total_amount": "10000", "clins-1-jedi_clin_type": "JEDI_CLIN_1", "clins-1-number": "12312", "clins-1-start_date": "01/01/2020", "clins-1-end_date": "01/01/2021", "clins-1-obligated_amount": "5000", + "clins-1-total_amount": "5000", } response = client.post( url_for( @@ -221,6 +223,7 @@ def test_task_orders_submit_form_step_three_add_clins_existing_to( "start_date": "01/01/2020", "end_date": "01/01/2021", "obligated_amount": "5000", + "total_amount": "10000", }, { "jedi_clin_type": "JEDI_CLIN_1", @@ -228,6 +231,7 @@ def test_task_orders_submit_form_step_three_add_clins_existing_to( "start_date": "01/01/2020", "end_date": "01/01/2021", "obligated_amount": "5000", + "total_amount": "10000", }, ] TaskOrders.create_clins(task_order.id, clin_list) @@ -240,6 +244,7 @@ def test_task_orders_submit_form_step_three_add_clins_existing_to( "clins-0-start_date": "01/01/2020", "clins-0-end_date": "01/01/2021", "clins-0-obligated_amount": "5000", + "clins-0-total_amount": "10000", } response = client.post( url_for( diff --git a/tests/test_access.py b/tests/test_access.py index e7e16040..90077269 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -530,6 +530,7 @@ def test_task_orders_new_post_routes(post_url_assert_status): "clins-0-start_date": "01/01/2020", "clins-0-end_date": "01/01/2021", "clins-0-obligated_amount": "5000", + "clins-0-total_amount": "10000", }, ), ] diff --git a/translations.yaml b/translations.yaml index 5b5b9552..137d0346 100644 --- a/translations.yaml +++ b/translations.yaml @@ -205,6 +205,9 @@ forms: start: PoP start date must be on or after {date}. scope_description: 'What do you plan to do on the cloud? Some examples might include migrating an existing application or creating a prototype. You don’t need to include a detailed plan of execution, but should list key requirements. This section will be reviewed by your contracting officer, but won’t be sent to the CCPO.

Not sure how to describe your scope? Read some examples to get some inspiration.

' scope_label: Cloud project scope + clin_funding_errors: + obligated_amount_error: Obligated amount must be less than or equal to total amount + funding_range_error: Dollar amount must be from $0.00 to $1,000,000,000.00 team_experience: built_1: Built, migrated, or consulted on 1-2 applications built_3: Built, migrated, or consulted on 3-5 applications @@ -395,7 +398,7 @@ task_orders: type: CLIN Type idiq_clin_description: Description (IDIQ CLIN) pop: PoP - amount: CLIN Value + total_amount: CLIN Value obligated: Amount Obligated form: add_clin: Add another CLIN @@ -406,7 +409,7 @@ task_orders: clin_description: "Refer to your task order to locate your Contract Line Item Numbers (CLINs)." clin_details: CLIN Details clin_funding: CLIN Funding - clin_number_label: CLIN Number + clin_number_label: CLIN clin_type_label: Corresponding IDIQ CLIN clin_remove_text: 'Do you want to remove ' clin_remove_confirm: Yes, remove CLIN @@ -415,6 +418,7 @@ task_orders: cloud_funding_text: Data must match with what is in your uploaded document. draft_alert_title: Your information has been saved draft_alert_message: You can return to the Task Order Builder to enter missing information. Once you are finished, you’ll be ready to submit this request. + total_funds_label: Total CLIN Value obligated_funds_label: Obligated Funds pop: Period of Performance pop_end: End Date