Refactor New Portfolio page according to designs.

New designs call for a streamlined New Portfolio page, with far
fewer input options. This commit refactors that page according to those
designs.

Some of the route functions in this commit refer to a "step 1" of creating
a new Portfolio. Though there is no "step 2" right now, the designs call
for a multistep flow for Portfolio creation process, so this commit sets
the stage for that.
This commit is contained in:
graham-dds 2019-12-11 11:30:27 -05:00
parent f9a3d2628e
commit a097a0ce61
13 changed files with 157 additions and 306 deletions

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

@ -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,21 @@ 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="You must select at least one defense component.")
],
)

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

@ -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

@ -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

@ -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,45 @@
{% 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="Name and Describe Portfolio", context="Step 1 of 2") }}
<base-form inline-template>
<form id="portfolio-create" action="{{ url_for('portfolios.create_portfolio') }}" method="POST">
{{ form.csrf_token }}
{{ TextInput(form.name, optional=False) }}
{{"forms.portfolio.name.help_text" | translate | safe }}
<div>
{{ TextInput(form.description, paragraph=True) }}
{{"forms.portfolio.description.help_text" | translate | safe }}
</div>
<div>
<strong class="h4">{{ "forms.portfolio.defense_component.label" | translate }}</strong>
{{ "forms.portfolio.defense_component.help_text" | translate | safe }}
{{ MultiCheckboxInput(form.defense_component, optional=False) }}
</div>
<div class='action-group'>
{{
SaveButton(
text=('common.save' | translate),
form="portfolio-create",
element="input",
)
}}
</div>
</form>
</base-form>
</main>
{% endblock %}

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

@ -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

@ -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

@ -166,8 +166,49 @@ 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
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 +217,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 +227,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