Merge branch 'staging' into gi-updates-wo-20191209

This commit is contained in:
Jay R. Newlin (PromptWorks) 2019-12-13 16:40:37 -05:00 committed by GitHub
commit b5a008c070
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 427 additions and 360 deletions

View File

@ -0,0 +1,26 @@
"""add unique constraint to task order number
Revision ID: 3bd8552f1c57
Revises: 802071bcd013
Create Date: 2019-12-10 12:45:17.535973
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '3bd8552f1c57' # pragma: allowlist secret
down_revision = '802071bcd013' # pragma: allowlist secret
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint('task_orders_number_key', 'task_orders', ['number'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('task_orders_number_key', 'task_orders', type_='unique')
# ### end Alembic commands ###

View File

@ -0,0 +1,40 @@
"""Remove unneeded portfolio columns
Revision ID: 802071bcd013
Revises: 67a2151d6269
Create Date: 2019-12-11 13:26:34.770480
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '802071bcd013' # pragma: allowlist secret
down_revision = '67a2151d6269' # pragma: allowlist secret
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('portfolios', 'dev_team')
op.drop_column('portfolios', 'complexity')
op.drop_column('portfolios', 'team_experience')
op.drop_column('portfolios', 'dev_team_other')
op.drop_column('portfolios', 'app_migration')
op.drop_column('portfolios', 'native_apps')
op.drop_column('portfolios', 'complexity_other')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('portfolios', sa.Column('complexity_other', sa.VARCHAR(), autoincrement=False, nullable=True))
op.add_column('portfolios', sa.Column('native_apps', sa.VARCHAR(), autoincrement=False, nullable=True))
op.add_column('portfolios', sa.Column('app_migration', sa.VARCHAR(), autoincrement=False, nullable=True))
op.add_column('portfolios', sa.Column('dev_team_other', sa.VARCHAR(), autoincrement=False, nullable=True))
op.add_column('portfolios', sa.Column('team_experience', sa.VARCHAR(), autoincrement=False, nullable=True))
op.add_column('portfolios', sa.Column('complexity', postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=True))
op.add_column('portfolios', sa.Column('dev_team', postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=True))
# ### end Alembic commands ###

View File

@ -1,9 +1,11 @@
import datetime
from sqlalchemy.exc import IntegrityError
from atst.database import db
from atst.models.clin import CLIN
from atst.models.task_order import TaskOrder, SORT_ORDERING
from . import BaseDomainClass
from .exceptions import AlreadyExistsError
class TaskOrders(BaseDomainClass):
@ -14,7 +16,12 @@ class TaskOrders(BaseDomainClass):
def create(cls, portfolio_id, number, clins, pdf):
task_order = TaskOrder(portfolio_id=portfolio_id, number=number, pdf=pdf)
db.session.add(task_order)
db.session.commit()
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
raise AlreadyExistsError("task_order")
TaskOrders.create_clins(task_order.id, clins)
@ -35,7 +42,12 @@ class TaskOrders(BaseDomainClass):
task_order.number = number
db.session.add(task_order)
db.session.commit()
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
raise AlreadyExistsError("task_order")
return task_order
@classmethod

View File

@ -3,112 +3,14 @@ from atst.utils.localization import translate
SERVICE_BRANCHES = [
("", "- Select -"),
("Air Force, Department of the", "Air Force, Department of the"),
("Army and Air Force Exchange Service", "Army and Air Force Exchange Service"),
("Army, Department of the", "Army, Department of the"),
("air_force", translate("forms.portfolio.defense_component.choices.air_force")),
("army", translate("forms.portfolio.defense_component.choices.army")),
(
"Defense Advanced Research Applications Agency",
"Defense Advanced Research Applications Agency",
"marine_corps",
translate("forms.portfolio.defense_component.choices.marine_corps"),
),
("Defense Commissary Agency", "Defense Commissary Agency"),
("Defense Contract Audit Agency", "Defense Contract Audit Agency"),
("Defense Contract Management Agency", "Defense Contract Management Agency"),
("Defense Finance & Accounting Service", "Defense Finance & Accounting Service"),
("Defense Health Agency", "Defense Health Agency"),
("Defense Information System Agency", "Defense Information System Agency"),
("Defense Intelligence Agency", "Defense Intelligence Agency"),
("Defense Legal Services Agency", "Defense Legal Services Agency"),
("Defense Logistics Agency", "Defense Logistics Agency"),
("Defense Media Activity", "Defense Media Activity"),
("Defense Micro Electronics Activity", "Defense Micro Electronics Activity"),
("Defense POW-MIA Accounting Agency", "Defense POW-MIA Accounting Agency"),
("Defense Security Cooperation Agency", "Defense Security Cooperation Agency"),
("Defense Security Service", "Defense Security Service"),
("Defense Technical Information Center", "Defense Technical Information Center"),
(
"Defense Technology Security Administration",
"Defense Technology Security Administration",
),
("Defense Threat Reduction Agency", "Defense Threat Reduction Agency"),
("DoD Education Activity", "DoD Education Activity"),
("DoD Human Recourses Activity", "DoD Human Recourses Activity"),
("DoD Inspector General", "DoD Inspector General"),
("DoD Test Resource Management Center", "DoD Test Resource Management Center"),
(
"Headquarters Defense Human Resource Activity ",
"Headquarters Defense Human Resource Activity ",
),
("Joint Staff", "Joint Staff"),
("Missile Defense Agency", "Missile Defense Agency"),
("National Defense University", "National Defense University"),
(
"National Geospatial Intelligence Agency (NGA)",
"National Geospatial Intelligence Agency (NGA)",
),
(
"National Oceanic and Atmospheric Administration (NOAA)",
"National Oceanic and Atmospheric Administration (NOAA)",
),
("National Reconnaissance Office", "National Reconnaissance Office"),
("National Reconnaissance Office (NRO)", "National Reconnaissance Office (NRO)"),
("National Security Agency (NSA)", "National Security Agency (NSA)"),
(
"National Security Agency-Central Security Service",
"National Security Agency-Central Security Service",
),
("Navy, Department of the", "Navy, Department of the"),
("Office of Economic Adjustment", "Office of Economic Adjustment"),
("Office of the Secretary of Defense", "Office of the Secretary of Defense"),
("Pentagon Force Protection Agency", "Pentagon Force Protection Agency"),
(
"Uniform Services University of the Health Sciences",
"Uniform Services University of the Health Sciences",
),
("US Cyber Command (USCYBERCOM)", "US Cyber Command (USCYBERCOM)"),
(
"US Special Operations Command (USSOCOM)",
"US Special Operations Command (USSOCOM)",
),
("US Strategic Command (USSTRATCOM)", "US Strategic Command (USSTRATCOM)"),
(
"US Transportation Command (USTRANSCOM)",
"US Transportation Command (USTRANSCOM)",
),
("Washington Headquarters Services", "Washington Headquarters Services"),
]
APP_MIGRATION = [
("on_premise", translate("forms.task_order.app_migration.on_premise")),
("cloud", translate("forms.task_order.app_migration.cloud")),
("both", translate("forms.task_order.app_migration.both")),
("none", translate("forms.task_order.app_migration.none")),
("not_sure", translate("forms.task_order.app_migration.not_sure")),
]
APPLICATION_COMPLEXITY = [
("storage", translate("forms.task_order.complexity.storage")),
("data_analytics", translate("forms.task_order.complexity.data_analytics")),
("conus", translate("forms.task_order.complexity.conus")),
("oconus", translate("forms.task_order.complexity.oconus")),
("tactical_edge", translate("forms.task_order.complexity.tactical_edge")),
("not_sure", translate("forms.task_order.complexity.not_sure")),
("other", translate("forms.task_order.complexity.other")),
]
DEV_TEAM = [
("civilians", translate("forms.task_order.dev_team.civilians")),
("military", translate("forms.task_order.dev_team.military")),
("contractor", translate("forms.task_order.dev_team.contractor")),
("other", translate("forms.task_order.dev_team.other")),
]
TEAM_EXPERIENCE = [
("none", translate("forms.task_order.team_experience.none")),
("planned", translate("forms.task_order.team_experience.planned")),
("built_1", translate("forms.task_order.team_experience.built_1")),
("built_3", translate("forms.task_order.team_experience.built_3")),
("built_many", translate("forms.task_order.team_experience.built_many")),
("navy", translate("forms.portfolio.defense_component.choices.navy")),
("other", translate("forms.portfolio.defense_component.choices.other")),
]
ENV_ROLE_NO_ACCESS = "No Access"

View File

@ -1,33 +1,25 @@
from wtforms.fields import (
RadioField,
SelectField,
SelectMultipleField,
StringField,
TextAreaField,
)
from wtforms.validators import Length, Optional
from wtforms.validators import Length, InputRequired
from wtforms.widgets import ListWidget, CheckboxInput
from .forms import BaseForm, remove_empty_string
from .forms import BaseForm
from atst.utils.localization import translate
from .data import (
APPLICATION_COMPLEXITY,
APP_MIGRATION,
DEV_TEAM,
SERVICE_BRANCHES,
TEAM_EXPERIENCE,
)
from .data import SERVICE_BRANCHES
class PortfolioForm(BaseForm):
name = StringField(
translate("forms.portfolio.name_label"),
translate("forms.portfolio.name.label"),
validators=[
Length(
min=4,
max=100,
message=translate("forms.portfolio.name_length_validation_message"),
message=translate("forms.portfolio.name.length_validation_message"),
)
],
)
@ -35,78 +27,25 @@ class PortfolioForm(BaseForm):
class PortfolioCreationForm(BaseForm):
name = StringField(
translate("forms.portfolio.name_label"),
translate("forms.portfolio.name.label"),
validators=[
Length(
min=4,
max=100,
message=translate("forms.portfolio.name_length_validation_message"),
message=translate("forms.portfolio.name.length_validation_message"),
)
],
)
defense_component = SelectField(
translate("forms.task_order.defense_component_label"),
description = TextAreaField(translate("forms.portfolio.description.label"),)
defense_component = SelectMultipleField(
choices=SERVICE_BRANCHES,
default="",
filters=[remove_empty_string],
)
description = TextAreaField(
translate("forms.task_order.scope_label"),
description=translate("forms.task_order.scope_description"),
)
app_migration = RadioField(
translate("forms.task_order.app_migration.label"),
description=translate("forms.task_order.app_migration.description"),
choices=APP_MIGRATION,
default="",
validators=[Optional()],
)
native_apps = RadioField(
translate("forms.task_order.native_apps.label"),
description=translate("forms.task_order.native_apps.description"),
choices=[("yes", "Yes"), ("no", "No"), ("not_sure", "Not Sure")],
default="",
validators=[Optional()],
)
complexity = SelectMultipleField(
translate("forms.task_order.complexity.label"),
description=translate("forms.task_order.complexity.description"),
choices=APPLICATION_COMPLEXITY,
default=None,
widget=ListWidget(prefix_label=False),
option_widget=CheckboxInput(),
)
complexity_other = StringField(
translate("forms.task_order.complexity_other_label"),
default=None,
filters=[remove_empty_string],
)
dev_team = SelectMultipleField(
translate("forms.task_order.dev_team.label"),
description=translate("forms.task_order.dev_team.description"),
choices=DEV_TEAM,
default=None,
widget=ListWidget(prefix_label=False),
option_widget=CheckboxInput(),
)
dev_team_other = StringField(
translate("forms.task_order.dev_team_other_label"),
default=None,
filters=[remove_empty_string],
)
team_experience = RadioField(
translate("forms.task_order.team_experience.label"),
description=translate("forms.task_order.team_experience.description"),
choices=TEAM_EXPERIENCE,
default="",
validators=[Optional()],
validators=[
InputRequired(
message=translate(
"forms.portfolio.defense_component.validation_message"
)
)
],
)

View File

@ -13,7 +13,7 @@ from numbers import Number
from .data import JEDI_CLIN_TYPES
from .fields import SelectField
from .forms import BaseForm
from .forms import BaseForm, remove_empty_string
from atst.utils.localization import translate
from flask import current_app as app
@ -134,7 +134,10 @@ class AttachmentForm(BaseForm):
class TaskOrderForm(BaseForm):
number = StringField(label=translate("forms.task_order.number_description"))
number = StringField(
label=translate("forms.task_order.number_description"),
filters=[remove_empty_string],
)
pdf = FormField(
AttachmentForm,
label=translate("task_orders.form.supporting_docs_size_limit"),

View File

@ -1,6 +1,5 @@
from sqlalchemy import Column, String
from sqlalchemy.orm import relationship
from sqlalchemy.types import ARRAY
from itertools import chain
from atst.models.base import Base
@ -19,19 +18,11 @@ class Portfolio(
id = types.Id()
name = Column(String, nullable=False)
description = Column(String)
defense_component = Column(
String, nullable=False
) # Department of Defense Component
app_migration = Column(String) # App Migration
complexity = Column(ARRAY(String)) # Application Complexity
complexity_other = Column(String)
description = Column(String)
dev_team = Column(ARRAY(String)) # Development Team
dev_team_other = Column(String)
native_apps = Column(String) # Native Apps
team_experience = Column(String) # Team Experience
applications = relationship(
"Application",
back_populates="portfolio",

View File

@ -39,7 +39,7 @@ class TaskOrder(Base, mixins.TimestampsMixin):
pdf_attachment_id = Column(ForeignKey("attachments.id"))
_pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id])
number = Column(String) # Task Order Number
number = Column(String, unique=True,) # Task Order Number
signer_dod_id = Column(String)
signed_at = Column(DateTime)

View File

@ -12,10 +12,9 @@ from atst.utils.flash import formatted_flash as flash
@portfolios_bp.route("/portfolios/new")
def new_portfolio():
def new_portfolio_step_1():
form = PortfolioCreationForm()
return render_template("portfolios/new.html", form=form)
return render_template("portfolios/new/step_1.html", form=form)
@portfolios_bp.route("/portfolios", methods=["POST"])
@ -28,7 +27,7 @@ def create_portfolio():
url_for("applications.portfolio_applications", portfolio_id=portfolio.id)
)
else:
return render_template("portfolios/new.html", form=form), 400
return render_template("portfolios/new/step_1.html", form=form), 400
@portfolios_bp.route("/portfolios/<portfolio_id>/reports")

View File

@ -10,7 +10,7 @@ from flask import (
from .blueprint import task_orders_bp
from atst.domain.authz.decorator import user_can_access_decorator as user_can
from atst.domain.exceptions import NoAccessError
from atst.domain.exceptions import NoAccessError, AlreadyExistsError
from atst.domain.task_orders import TaskOrders
from atst.forms.task_order import TaskOrderForm, SignatureForm
from atst.models.permissions import Permissions
@ -50,7 +50,26 @@ def render_task_orders_edit(
return render_template(template, **render_args)
def update_task_order(
def update_task_order(form, portfolio_id=None, task_order_id=None, flash_invalid=True):
if form.validate(flash_invalid=flash_invalid):
task_order = None
try:
if task_order_id:
task_order = TaskOrders.update(task_order_id, **form.data)
portfolio_id = task_order.portfolio_id
else:
task_order = TaskOrders.create(portfolio_id, **form.data)
return task_order
except AlreadyExistsError:
flash("task_order_number_error", to_number=form.data["number"])
return False
else:
return False
def update_and_render_next(
form_data, next_page, current_template, portfolio_id=None, task_order_id=None
):
form = None
@ -60,14 +79,8 @@ def update_task_order(
else:
form = TaskOrderForm(form_data)
if form.validate():
task_order = None
if task_order_id:
task_order = TaskOrders.update(task_order_id, **form.data)
portfolio_id = task_order.portfolio_id
else:
task_order = TaskOrders.create(portfolio_id, **form.data)
task_order = update_task_order(form, portfolio_id, task_order_id)
if task_order:
return redirect(url_for(next_page, task_order_id=task_order.id))
else:
return (
@ -149,7 +162,7 @@ def submit_form_step_one_add_pdf(portfolio_id=None, task_order_id=None):
next_page = "task_orders.form_step_two_add_number"
current_template = "task_orders/step_1.html"
return update_task_order(
return update_and_render_next(
form_data,
next_page,
current_template,
@ -176,12 +189,8 @@ def cancel_edit(task_order_id=None, portfolio_id=None):
else:
form = TaskOrderForm(form_data)
if form.validate(flash_invalid=False):
task_order = None
if task_order_id:
task_order = TaskOrders.update(task_order_id, **form.data)
else:
task_order = TaskOrders.create(portfolio_id, **form.data)
update_task_order(form, portfolio_id, task_order_id, flash_invalid=False)
elif not save and task_order_id:
TaskOrders.delete(task_order_id)
@ -205,7 +214,7 @@ def submit_form_step_two_add_number(task_order_id):
next_page = "task_orders.form_step_three_add_clins"
current_template = "task_orders/step_2.html"
return update_task_order(
return update_and_render_next(
form_data, next_page, current_template, task_order_id=task_order_id
)
@ -225,7 +234,7 @@ def submit_form_step_three_add_clins(task_order_id):
next_page = "task_orders.form_step_four_review"
current_template = "task_orders/step_3.html"
return update_task_order(
return update_and_render_next(
form_data, next_page, current_template, task_order_id=task_order_id
)

View File

@ -165,6 +165,11 @@ MESSAGES = {
"message_template": translate("task_orders.form.draft_alert_message"),
"category": "warning",
},
"task_order_number_error": {
"title_template": "",
"message_template": """{{ 'flash.task_order_number_error.message' | translate({ 'to_number': to_number }) }}""",
"category": "error",
},
"task_order_submitted": {
"title_template": "Your Task Order has been uploaded successfully.",
"message_template": """

View File

@ -0,0 +1,102 @@
import { mount } from '@vue/test-utils'
import multicheckboxinput from '../multi_checkbox_input'
import { makeTestWrapper } from '../../test_utils/component_test_helpers'
const WrapperComponent = makeTestWrapper({
components: {
multicheckboxinput,
},
templatePath: 'multi_checkbox_input_template.html',
data: function() {
const { initialvalue, optional } = this.initialData
return { initialvalue, optional }
},
})
describe('MultiCheckboxInput Renders Correctly', () => {
it('Should initialize unchecked and with no validation showing', () => {
const wrapper = mount(WrapperComponent, {
propsData: {
name: 'testCheck',
initialData: {
initialvalue: [],
},
},
})
expect(wrapper.contains('.usa-input--success')).toBe(false)
expect(wrapper.contains('.usa-input--error')).toBe(false)
expect(wrapper.find('.usa-input input[value="a"]').element.checked).toBe(
false
)
expect(wrapper.find('.usa-input input[value="b"]').element.checked).toBe(
false
)
})
it('Should initialize with "a" checked', () => {
const wrapper = mount(WrapperComponent, {
propsData: {
name: 'testCheck',
initialData: {
initialvalue: ['a'],
},
},
})
expect(wrapper.find('.usa-input input[value="a"]').element.checked).toBe(
true
)
expect(wrapper.find('.usa-input input[value="b"]').element.checked).toBe(
false
)
})
})
describe('Multicheckbox shows validation states correctly', () => {
it('Should be valid when any checkbox is clicked', () => {
const wrapper = mount(WrapperComponent, {
propsData: {
name: 'testCheck',
initialData: { initialvalue: [] },
},
})
wrapper.find('.usa-input input[value="a"]').setChecked()
expect(wrapper.contains('.usa-input--success')).toBe(true)
expect(wrapper.contains('.usa-input--error')).toBe(false)
})
it('Should be invalid when no checkboxes are checked', () => {
const wrapper = mount(WrapperComponent, {
propsData: {
name: 'testCheck',
initialData: {
initialvalue: [],
},
},
})
// Check and then uncheck a checkbox
const checkboxA = wrapper.find('.usa-input input[value="a"]')
checkboxA.setChecked()
checkboxA.setChecked(false)
expect(wrapper.contains('.usa-input--error')).toBe(true)
expect(wrapper.contains('.usa-input--success')).toBe(false)
})
it('Should be valid when no checkboxes are checked but it is optional', () => {
const wrapper = mount(WrapperComponent, {
propsData: {
name: 'testCheck',
initialData: { initialvalue: [], optional: true },
},
})
// Check and then uncheck a checkbox
const checkboxA = wrapper.find('.usa-input input[value="a"]')
checkboxA.setChecked()
checkboxA.setChecked(false)
expect(wrapper.contains('.usa-input--error')).toBe(false)
expect(wrapper.contains('.usa-input--success')).toBe(true)
})
})

View File

@ -13,22 +13,14 @@ export default {
type: Array,
default: () => [],
},
initialOtherValue: String,
optional: Boolean,
},
data: function() {
const showError = (this.initialErrors && this.initialErrors.length) || false
return {
showError: showError,
showValid: !showError && this.initialValue.length > 0,
showError: this.initialErrors.length > 0,
showValid: false,
validationError: this.initialErrors.join(' '),
otherChecked: this.initialValue.includes('other')
? true
: this.otherChecked,
otherText: this.initialValue.includes('other')
? this.initialOtherValue
: '',
selections: this.initialValue,
}
},
@ -36,17 +28,15 @@ export default {
methods: {
onInput: function(e) {
emitFieldChange(this)
this.showError = false
this.showValid = true
},
otherToggle: function() {
this.otherChecked = !this.otherChecked
this.showError = !this.valid
this.showValid = !this.showError
this.validationError = 'This field is required.'
},
},
computed: {
valid: function() {
return this.optional || this.showValid
return this.optional || this.selections.length > 0
},
},
}

View File

@ -15,7 +15,7 @@ PASSWORD = os.getenv("ATAT_BA_PASSWORD", "")
DISABLE_VERIFY = os.getenv("DISABLE_VERIFY", "true").lower() == "true"
# Alpha numerics for random entity names
LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890"
LETTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890" #pragma: allowlist secret
NEW_PORTFOLIO_CHANCE = 10
NEW_APPLICATION_CHANCE = 10
@ -141,15 +141,8 @@ def create_portfolio(l):
new_portfolio_form = l.client.get("/portfolios/new")
new_portfolio_body = {
"name": f"Load Test Created - {''.join(choices(LETTERS, k=5))}",
"defense_component": "Army, Department of the",
"defense_component": "army",
"description": "Test",
"app_migration": "none",
"native_apps": "yes",
"complexity": "storage",
"complexity_other": "",
"dev_team": "civilians",
"dev_team_other": "",
"team_experience": "none",
"csrf_token": get_csrf_token(new_portfolio_form),
}

View File

@ -1,6 +1,9 @@
// Form Grid
.form-row {
margin: ($gap * 4) 0;
&--separated {
border-bottom: $color-gray-lighter 1px solid;
}
.form-col {
flex-grow: 1;

View File

@ -45,19 +45,8 @@
<ul>
{% for choice in field.choices %}
<li>
{% if choice[0] != 'other' %}
<input type='checkbox' name='{{ field.name }}' id='{{ field.name }}-{{ loop.index0 }}' value='{{ choice[0] }}' v-model="selections"/>
<label for='{{ field.name }}-{{ loop.index0 }}'>{{ choice[1] | safe }}</label>
{% else %}
<input @click="otherToggle" type='checkbox' name='{{ field.name }}' id='{{ field.name }}-{{ loop.index0 }}' value='other' v-model="selections"/>
<label for='{{ field.name }}-{{ loop.index0 }}'>{{ choice[1] | safe }}</label>
{% if other_input_field %}
<div v-show="otherChecked">
<input type='text' name='{{ other_input_field.name}}' id='{{ field.name }}-other' v-model:value="otherText" aria-expanded='false' />
</div>
{% endif %}
{% endif %}
</li>
{% endfor %}
</ul>

View File

@ -14,7 +14,7 @@
{% endif %}
{% call StickyCTA(sticky_header) %}
<a href="{{ url_for("portfolios.new_portfolio") }}" class="usa-button-primary">
<a href="{{ url_for("portfolios.new_portfolio_step_1") }}" class="usa-button-primary">
{{ "home.add_portfolio_button_text" | translate }}
</a>
{% endcall %}

View File

@ -1,54 +0,0 @@
{% from "components/multi_checkbox_input.html" import MultiCheckboxInput %}
{% from "components/options_input.html" import OptionsInput %}
{% from "components/save_button.html" import SaveButton %}
{% from "components/text_input.html" import TextInput %}
{% extends "base.html" %}
{% block content %}
<main class="usa-section usa-content">
{% include "fragments/flash.html" %}
<h1>New Portfolio Form</h1>
<base-form inline-template>
<form class="panel__content" id="portfolio-create" action="{{ url_for('portfolios.create_portfolio') }}" method="POST">
{{ form.csrf_token }}
{{ TextInput(form.name, optional=False) }}
{{ OptionsInput(form.defense_component, optional=False) }}
{{ TextInput(form.description, paragraph=True) }}
<h3 id="reporting" class="subheading">{{ "task_orders.new.app_info.project_title" | translate }}</h3>
<hr>
{{ OptionsInput(form.app_migration) }}
{{ OptionsInput(form.native_apps) }}
<p>{{ "forms.task_order.native_apps.not_sure_help" | translate }}</p>
{{ MultiCheckboxInput(form.complexity, form.complexity_other) }}
<hr>
<h3 class="subheading">{{ "task_orders.new.app_info.team_title" | translate }}</h3>
<p>{{ "task_orders.new.app_info.subtitle" | translate }}</p>
{{ MultiCheckboxInput(form.dev_team, form.dev_team_other) }}
{{ OptionsInput(form.team_experience) }}
<hr>
<div class='action-group'>
{{
SaveButton(
text=('common.save' | translate),
form="portfolio-create",
element="input",
)
}}
</div>
</form>
</base-form>
</main>
{% endblock %}

View File

@ -0,0 +1,52 @@
{% from "components/multi_checkbox_input.html" import MultiCheckboxInput %}
{% from "components/options_input.html" import OptionsInput %}
{% from "components/save_button.html" import SaveButton %}
{% from "components/text_input.html" import TextInput %}
{% from "components/sticky_cta.html" import StickyCTA %}
{% extends "base.html" %}
{% block content %}
<main class="usa-section usa-content">
{% include "fragments/flash.html" %}
<div class='portfolio-header__name'>
<p>{{ "portfolios.header" | translate }}</p>
<h1>{{ "New Portfolio" }}</h1>
</div>
{{ StickyCTA(text="Create New Portfolio") }}
<base-form inline-template>
<form id="portfolio-create" action="{{ url_for('portfolios.create_portfolio') }}" method="POST">
{{ form.csrf_token }}
<div class="form-row form-row--separated">
<div class="form-col">
{{ TextInput(form.name, optional=False) }}
{{"forms.portfolio.name.help_text" | translate | safe }}
</div>
</div>
<div class="form-row form-row--separated">
<div class="form-col">
{{ TextInput(form.description, paragraph=True) }}
{{"forms.portfolio.description.help_text" | translate | safe }}
</div>
</div>
<div class="form-row">
<div class="form-col">
{{ MultiCheckboxInput(form.defense_component, optional=False) }}
{{ "forms.portfolio.defense_component.help_text" | translate | safe }}
</div>
</div>
<div class='action-group'>
{{
SaveButton(
text=('common.save' | translate),
form="portfolio-create",
element="input",
)
}}
</div>
</form>
</base-form>
</main>
{% endblock %}

View File

@ -19,7 +19,7 @@
{% if task_orders|length > 0 %}
{% for task_order in task_orders %}
{% set to_number %}
{% if task_order.number != "" %}
{% if task_order.number != None %}
Task Order #{{ task_order.number }}
{% else %}
New Task Order

View File

@ -2,6 +2,7 @@ import pytest
from datetime import date, timedelta
from decimal import Decimal
from atst.domain.exceptions import AlreadyExistsError
from atst.domain.task_orders import TaskOrders
from atst.models import Attachment
from atst.models.task_order import TaskOrder, SORT_ORDERING, Status
@ -154,3 +155,18 @@ def test_task_order_sort_by_status():
assert len(sorted_by_status["Expired"]) == 2
assert len(sorted_by_status["Unsigned"]) == 1
assert list(sorted_by_status.keys()) == [status.value for status in SORT_ORDERING]
def test_create_enforces_unique_number():
portfolio = PortfolioFactory.create()
number = "1234567890123"
assert TaskOrders.create(portfolio.id, number, [], None)
with pytest.raises(AlreadyExistsError):
TaskOrders.create(portfolio.id, number, [], None)
def test_update_enforces_unique_number():
task_order = TaskOrderFactory.create()
dupe_task_order = TaskOrderFactory.create()
with pytest.raises(AlreadyExistsError):
TaskOrders.update(dupe_task_order.id, task_order.number, [], None)

View File

@ -105,13 +105,7 @@ class PortfolioFactory(Base):
name = factory.Faker("domain_word")
defense_component = factory.LazyFunction(random_service_branch)
app_migration = random_choice(data.APP_MIGRATION)
complexity = [random_choice(data.APPLICATION_COMPLEXITY)]
description = factory.Faker("sentence")
dev_team = [random_choice(data.DEV_TEAM)]
native_apps = random.choice(["yes", "no", "not_sure"])
team_experience = random_choice(data.TEAM_EXPERIENCE)
@classmethod
def _create(cls, model_class, *args, **kwargs):

View File

@ -2,7 +2,7 @@ import datetime
from dateutil.relativedelta import relativedelta
from flask import current_app as app
from atst.forms.task_order import CLINForm
from atst.forms.task_order import CLINForm, TaskOrderForm
from atst.models import JEDICLINType
from atst.utils.localization import translate
@ -106,3 +106,9 @@ def test_clin_form_dollar_amounts_out_of_range():
assert (
translate("forms.task_order.clin_funding_errors.funding_range_error")
) in invalid_clin_form.obligated_amount.errors
def test_no_number():
http_request_form_data = {}
form = TaskOrderForm(http_request_form_data)
assert form.data["number"] is None

View File

@ -1,10 +1,11 @@
import pytest
from bs4 import BeautifulSoup
from flask import Markup
from wtforms import Form, FormField
from wtforms.fields import StringField
from wtforms.validators import InputRequired
from wtforms.widgets import CheckboxInput
from wtforms.widgets import ListWidget, CheckboxInput
from atst.forms.task_order import CLINForm
from atst.forms.task_order import TaskOrderForm
@ -56,6 +57,12 @@ def checkbox_input_macro(env):
return getattr(checkbox_template.module, "CheckboxInput")
@pytest.fixture
def multi_checkbox_input_macro(env):
multi_checkbox_template = env.get_template("components/multi_checkbox_input.html")
return getattr(multi_checkbox_template.module, "MultiCheckboxInput")
@pytest.fixture
def initial_value_form(scope="function"):
return InitialValueForm()
@ -82,6 +89,20 @@ def test_make_checkbox_input_template(checkbox_input_macro, initial_value_form):
write_template(rendered_checkbox_macro, "checkbox_input_template.html")
def test_make_multi_checkbox_input_template(
multi_checkbox_input_macro, initial_value_form
):
initial_value_form.datafield.widget = ListWidget()
initial_value_form.datafield.option_widget = CheckboxInput()
initial_value_form.datafield.choices = [("a", "A"), ("b", "B")]
rendered_multi_checkbox_input_macro = multi_checkbox_input_macro(
initial_value_form.datafield, optional=Markup("'optional'")
)
write_template(
rendered_multi_checkbox_input_macro, "multi_checkbox_input_template.html"
)
def test_make_upload_input_template(upload_input_macro, task_order_form):
rendered_upload_macro = upload_input_macro(task_order_form.pdf)
write_template(rendered_upload_macro, "upload_input_template.html")

View File

@ -18,7 +18,7 @@ def test_new_portfolio(client, user_session):
user = UserFactory.create()
user_session(user)
response = client.get(url_for("portfolios.new_portfolio"))
response = client.get(url_for("portfolios.new_portfolio_step_1"))
assert response.status_code == 200
@ -34,7 +34,7 @@ def test_create_portfolio_success(client, user_session):
data={
"name": "My project name",
"description": "My project description",
"defense_component": "Air Force, Department of the",
"defense_component": "army",
},
)

View File

@ -170,6 +170,26 @@ def test_task_orders_submit_form_step_two_add_number(client, user_session, task_
assert task_order.number == "1234567890"
def test_task_orders_submit_form_step_two_enforces_unique_number(
client, user_session, task_order, session
):
number = "1234567890123"
dupe_task_order = TaskOrderFactory.create(number=number)
portfolio = task_order.portfolio
user_session(task_order.portfolio.owner)
form_data = {"number": number}
session.begin_nested()
response = client.post(
url_for(
"task_orders.submit_form_step_two_add_number", task_order_id=task_order.id
),
data=form_data,
)
session.rollback()
assert response.status_code == 400
def test_task_orders_submit_form_step_two_add_number_existing_to(
client, user_session, task_order
):

View File

@ -25,7 +25,7 @@ _NO_ACCESS_CHECK_REQUIRED = _NO_LOGIN_REQUIRED + [
"dev.test_email", # dev tool
"portfolios.accept_invitation", # available to all users; access control is built into invitation logic
"portfolios.create_portfolio", # create a portfolio
"portfolios.new_portfolio", # all users can create a portfolio
"portfolios.new_portfolio_step_1", # all users can create a portfolio
"task_orders.get_started", # all users can start a new TO
"users.update_user", # available to all users
"users.user", # available to all users

View File

@ -123,6 +123,8 @@ flash:
new_ppoc_message: 'You have successfully added {ppoc_name} as the primary point of contact. You are no longer the PPoC.'
new_ppoc_title: Primary point of contact updated
success: Success!
task_order_number_error:
message: 'The TO number has already been entered for a JEDI task order #{to_number}. Please double-check the TO number you are entering. If you believe this is in error, please contact support@cloud.mil.'
new_application_member:
title: "{user_name}'s invitation has been sent"
message: "{user_name}'s access to this Application is pending until they sign in for the first time."
@ -166,8 +168,50 @@ forms:
portfolio_mgmt: Portfolio management
reporting: Reporting
portfolio:
name_label: Portfolio name
name_length_validation_message: Portfolio names can be between 4-100 characters
name:
label: Portfolio Name
length_validation_message: Portfolio names can be between 4-100 characters
help_text: |
<div>
<p>
Naming can be difficult. Choose a name that is descriptive enough for users to identify the Portfolio. You may consider naming based on your organization.
</p>
<p>
<strong>Writer's Block? A naming example</strong>
<ul>
<li>Design Support for Army Developers</li>
</ul>
</p>
</div>
description:
label: Portfolio Description
help_text: |
<div>
<p>
Add a brief one to two sentence description of your Portfolio. Consider this your statement of work.
</p>
<p>
<strong>Writer's Block? A description example includes:</strong>
<ul>
<li>Build security applications for FOB Clark</li>
</ul>
</p>
</div>
defense_component:
label: "Select DoD component(s) funding your Portfolio:"
choices:
air_force: Air Force
army: Army
marine_corps: Marine Corps
navy: Navy
other: Other
validation_message: You must select at least one defense component.
help_text: |
<p>
Select the DOD component(s) that will fund all Applications within this Portfolio.
In JEDI, multiple DoD organizations can fund the same Portfolio.<br/>
Select all that apply.<br/>
</p>
attachment:
object_name:
length_error: Object name may be no longer than 40 characters.
@ -176,41 +220,8 @@ forms:
task_order:
upload_error: There was an error uploading your file. Please try again. If you encounter repeated problems uploading this file, please contact CCPO.
size_error: The file you have selected is too large. Please choose a file no larger than 64MB.
app_migration:
both: 'Yes, migrating from both an on-premise data center <strong>and</strong> another cloud provider'
cloud: 'Yes, migrating from another cloud provider'
description: Do you plan to migrate one or more existing application(s) to the cloud?
label: App migration
none: Not planning to migrate any applications
not_sure: Not sure
on_premise: 'Yes, migrating from an on-premise data center'
complexity:
conus: CONUS access
data_analytics: Data analytics
description: Which of these describes how complex your team's use of the cloud will be? Select all that apply.
label: Project complexity
not_sure: Not sure
oconus: OCONUS access
other: Other
storage: Storage
tactical_edge: Tactical edge access
complexity_other_label: Project Complexity Other
defense_component_label: Department of Defense component
dev_team:
civilians: Government civilians
contractor: Contractor
description: Who is building your cloud application(s)? Select all that apply.
label: Development team
military: Military
other: Other <em class='description'>(E.g. University or other partner)</em>
dev_team_other_label: Development Team Other
defense_component_label: Select DoD component(s) funding your Portfolio
file_format_not_allowed: Only PDF or PNG files can be uploaded.
native_apps:
description: Do you plan to develop any applications in the cloud?
label: Native apps
'no': 'No, not planning to develop natively in the cloud'
not_sure: 'Not sure, unsure if planning to develop natively in the cloud'
not_sure_help: Not sure? Talk to your technical lead about where and how they plan on developing your application.
number_description: Task order number (13 digits)
pop_errors:
date_order: PoP start date must be before end date.
@ -219,8 +230,6 @@ forms:
end_pre_contract: PoP end date must be after or on {date}.
start_past_contract: PoP start date must be before or on {date}.
start_pre_contract: 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 dont need to include a detailed plan of execution, but should list key requirements. This section will be reviewed by your contracting officer, but wont be sent to the CCPO. <p>Not sure how to describe your scope? <a href="#">Read some examples</a> to get some inspiration.</p>'
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