Add New Portfolio Workflow

This commit is contained in:
George Drummond 2019-05-30 11:44:04 -04:00
parent ad5d704fa8
commit f7562714cb
No known key found for this signature in database
GPG Key ID: 296DD6077123BF17
12 changed files with 312 additions and 15 deletions

View File

@ -0,0 +1,42 @@
"""Portfolio demographics
Revision ID: 988f8b23fbf6
Revises: 8467440c4ae6
Create Date: 2019-05-31 14:00:31.197803
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '988f8b23fbf6'
down_revision = '8467440c4ae6'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('portfolios', sa.Column('app_migration', sa.String(), nullable=True))
op.add_column('portfolios', sa.Column('complexity', sa.ARRAY(sa.String()), nullable=True))
op.add_column('portfolios', sa.Column('complexity_other', sa.String(), nullable=True))
op.add_column('portfolios', sa.Column('description', sa.String(), nullable=True))
op.add_column('portfolios', sa.Column('dev_team', sa.ARRAY(sa.String()), nullable=True))
op.add_column('portfolios', sa.Column('dev_team_other', sa.String(), nullable=True))
op.add_column('portfolios', sa.Column('native_apps', sa.String(), nullable=True))
op.add_column('portfolios', sa.Column('team_experience', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('portfolios', 'team_experience')
op.drop_column('portfolios', 'native_apps')
op.drop_column('portfolios', 'dev_team_other')
op.drop_column('portfolios', 'dev_team')
op.drop_column('portfolios', 'description')
op.drop_column('portfolios', 'complexity_other')
op.drop_column('portfolios', 'complexity')
op.drop_column('portfolios', 'app_migration')
# ### end Alembic commands ###

View File

@ -15,10 +15,8 @@ class PortfolioError(Exception):
class Portfolios(object): class Portfolios(object):
@classmethod @classmethod
def create(cls, user, name, defense_component=None): def create(cls, user, portfolio_attrs):
portfolio = PortfoliosQuery.create( portfolio = PortfoliosQuery.create(**portfolio_attrs)
name=name, defense_component=defense_component
)
perms_sets = PermissionSets.get_many(PortfolioRoles.PORTFOLIO_PERMISSION_SETS) perms_sets = PermissionSets.get_many(PortfolioRoles.PORTFOLIO_PERMISSION_SETS)
Portfolios._create_portfolio_role( Portfolios._create_portfolio_role(
user, user,

View File

@ -1,9 +1,24 @@
from wtforms.fields import StringField from wtforms.fields import (
from wtforms.validators import Length RadioField,
SelectField,
SelectMultipleField,
StringField,
TextAreaField,
)
from wtforms.validators import Length, Optional
from wtforms.widgets import ListWidget, CheckboxInput
from .forms import BaseForm from .forms import BaseForm
from atst.utils.localization import translate from atst.utils.localization import translate
from .data import (
APPLICATION_COMPLEXITY,
APP_MIGRATION,
DEV_TEAM,
SERVICE_BRANCHES,
TEAM_EXPERIENCE,
)
class PortfolioForm(BaseForm): class PortfolioForm(BaseForm):
name = StringField( name = StringField(
@ -16,3 +31,84 @@ class PortfolioForm(BaseForm):
) )
], ],
) )
class PortfolioCreationForm(BaseForm):
name = StringField(
translate("forms.portfolio.name_label"),
validators=[
Length(
min=4,
max=100,
message=translate("forms.portfolio.name_length_validation_message"),
)
],
)
defense_component = SelectField(
translate("forms.task_order.defense_component_label"),
choices=SERVICE_BRANCHES,
default="",
filters=[BaseForm.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,
filters=[BaseForm.remove_empty_string],
widget=ListWidget(prefix_label=False),
option_widget=CheckboxInput(),
)
complexity_other = StringField(
translate("forms.task_order.complexity_other_label"),
default=None,
filters=[BaseForm.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,
filters=[BaseForm.remove_empty_string],
widget=ListWidget(prefix_label=False),
option_widget=CheckboxInput(),
)
dev_team_other = StringField(
translate("forms.task_order.dev_team_other_label"),
default=None,
filters=[BaseForm.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()],
)

View File

@ -1,5 +1,6 @@
from sqlalchemy import Column, String from sqlalchemy import Column, String
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.types import ARRAY
from itertools import chain from itertools import chain
from atst.models import Base, mixins, types from atst.models import Base, mixins, types
@ -16,6 +17,15 @@ class Portfolio(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
name = Column(String) name = Column(String)
defense_component = Column(String) # Department of Defense Component defense_component = Column(String) # 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( applications = relationship(
"Application", "Application",
back_populates="portfolio", back_populates="portfolio",

View File

@ -1,8 +1,9 @@
from datetime import date, timedelta from datetime import date, timedelta
from flask import render_template, request as http_request, g from flask import redirect, render_template, url_for, request as http_request, g
from . import portfolios_bp from . import portfolios_bp
from atst.forms.portfolio import PortfolioCreationForm
from atst.domain.reports import Reports from atst.domain.reports import Reports
from atst.domain.portfolios import Portfolios from atst.domain.portfolios import Portfolios
from atst.models.permissions import Permissions from atst.models.permissions import Permissions
@ -19,6 +20,26 @@ def portfolios():
return render_template("portfolios/blank_slate.html") return render_template("portfolios/blank_slate.html")
@portfolios_bp.route("/portfolios/new")
def new_portfolio():
form = PortfolioCreationForm()
return render_template("portfolios/new.html", form=form)
@portfolios_bp.route("/portfolios", methods=["POST"])
def create_portfolio():
form = PortfolioCreationForm(http_request.form)
if form.validate():
portfolio = Portfolios.create(user=g.current_user, portfolio_attrs=form.data)
return redirect(
url_for("applications.portfolio_applications", portfolio_id=portfolio.id)
)
else:
return render_template("portfolios/new.html", form=form), 400
@portfolios_bp.route("/portfolios/<portfolio_id>/reports") @portfolios_bp.route("/portfolios/<portfolio_id>/reports")
@user_can(Permissions.VIEW_PORTFOLIO_REPORTS, message="view portfolio reports") @user_can(Permissions.VIEW_PORTFOLIO_REPORTS, message="view portfolio reports")
def reports(portfolio_id): def reports(portfolio_id):

View File

@ -242,9 +242,11 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow):
pf = Portfolios.get(self.user, self.portfolio_id) pf = Portfolios.get(self.user, self.portfolio_id)
else: else:
pf = Portfolios.create( pf = Portfolios.create(
self.user, user=self.user,
self.form.portfolio_name.data, portfolio_attrs={
self.form.defense_component.data, "name": self.form.portfolio_name.data,
"defense_component": self.form.defense_component.data,
},
) )
self._task_order = TaskOrders.create(portfolio=pf, creator=self.user) self._task_order = TaskOrders.create(portfolio=pf, creator=self.user)
TaskOrders.update(self.task_order, **self.task_order_form_data) TaskOrders.update(self.task_order, **self.task_order_form_data)

View File

@ -234,7 +234,8 @@ def create_demo_portfolio(name, data):
return return
portfolio = Portfolios.create( portfolio = Portfolios.create(
portfolio_owner, name=name, defense_component=random_service_branch() user=portfolio_owner,
portfolio_attrs={"name": name, "defense_component": random_service_branch()},
) )
add_task_orders_to_portfolio(portfolio) add_task_orders_to_portfolio(portfolio)
@ -259,14 +260,22 @@ def seed_db():
create_demo_portfolio("B-Wing", MockReportingProvider.REPORT_FIXTURE_MAP["B-Wing"]) create_demo_portfolio("B-Wing", MockReportingProvider.REPORT_FIXTURE_MAP["B-Wing"])
tie_interceptor = Portfolios.create( tie_interceptor = Portfolios.create(
amanda, name="TIE Interceptor", defense_component=random_service_branch() user=amanda,
portfolio_attrs={
"name": "TIE Interceptor",
"defense_component": random_service_branch(),
},
) )
add_task_orders_to_portfolio(tie_interceptor) add_task_orders_to_portfolio(tie_interceptor)
add_members_to_portfolio(tie_interceptor) add_members_to_portfolio(tie_interceptor)
add_applications_to_portfolio(tie_interceptor) add_applications_to_portfolio(tie_interceptor)
tie_fighter = Portfolios.create( tie_fighter = Portfolios.create(
amanda, name="TIE Fighter", defense_component=random_service_branch() user=amanda,
portfolio_attrs={
"name": "TIE Fighter",
"defense_component": random_service_branch(),
},
) )
add_task_orders_to_portfolio(tie_fighter) add_task_orders_to_portfolio(tie_fighter)
add_members_to_portfolio(tie_fighter) add_members_to_portfolio(tie_fighter)
@ -278,7 +287,11 @@ def seed_db():
ship = random.choice(ships) ship = random.choice(ships)
ships.remove(ship) ships.remove(ship)
portfolio = Portfolios.create( portfolio = Portfolios.create(
user, name=ship, defense_component=random_service_branch() user=user,
portfolio_attrs={
"name": ship,
"defense_component": random_service_branch(),
},
) )
add_task_orders_to_portfolio(portfolio) add_task_orders_to_portfolio(portfolio)
add_members_to_portfolio(portfolio) add_members_to_portfolio(portfolio)

View File

@ -4,7 +4,7 @@
<main class="usa-section usa-content"> <main class="usa-section usa-content">
<a href="" class="usa-button-primary"> <a href="{{ url_for("portfolios.new_portfolio") }}" class="usa-button-primary">
{{ "home.add_portfolio_button_text" | translate }} {{ "home.add_portfolio_button_text" | translate }}
</a> </a>

View File

@ -0,0 +1,53 @@
{% 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">
<h1>New Portfolio Form</h1>
<base-form inline-template>
<form id="portfolio-create" action="{{ url_for('portfolios.create_portfolio') }}" method="POST">
{{ form.csrf_token }}
{{ TextInput(form.name) }}
{{ OptionsInput(form.defense_component) }}
{{ 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

@ -106,6 +106,13 @@ class PortfolioFactory(Base):
name = factory.Faker("domain_word") name = factory.Faker("domain_word")
defense_component = factory.LazyFunction(random_service_branch) 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 @classmethod
def _create(cls, model_class, *args, **kwargs): def _create(cls, model_class, *args, **kwargs):
with_applications = kwargs.pop("applications", []) with_applications = kwargs.pop("applications", [])

View File

@ -8,6 +8,59 @@ from tests.factories import (
UserFactory, UserFactory,
) )
from atst.utils.localization import translate from atst.utils.localization import translate
from atst.domain.portfolios import Portfolios
from atst.domain.portfolios.query import PortfoliosQuery
def test_new_portfolio(client, user_session):
user = UserFactory.create()
user_session(user)
response = client.get(url_for("portfolios.new_portfolio"))
assert response.status_code == 200
def test_create_portfolio_success(client, user_session):
user = UserFactory.create()
user_session(user)
original_portfolio_count = len(PortfoliosQuery.get_all())
response = client.post(
url_for("portfolios.create_portfolio"),
data={
"name": "My project name",
"description": "My project description",
"defense_component": "Air Force, Department of the",
},
)
assert response.status_code == 302
assert len(PortfoliosQuery.get_all()) == original_portfolio_count + 1
new_portfolio = Portfolios.for_user(user=user)[-1]
assert (
url_for("applications.portfolio_applications", portfolio_id=new_portfolio.id)
in response.location
)
assert new_portfolio.owner == user
def test_create_portfolio_failure(client, user_session):
user = UserFactory.create()
user_session(user)
original_portfolio_count = len(PortfoliosQuery.get_all())
response = client.post(
url_for("portfolios.create_portfolio"),
data={"name": "My project name", "description": "My project description"},
)
assert response.status_code == 400
assert len(PortfoliosQuery.get_all()) == original_portfolio_count
def test_portfolio_index_with_existing_portfolios(client, user_session): def test_portfolio_index_with_existing_portfolios(client, user_session):

View File

@ -26,6 +26,8 @@ _NO_ACCESS_CHECK_REQUIRED = _NO_LOGIN_REQUIRED + [
"applications.accept_invitation", # available to all users; access control is built into invitation logic "applications.accept_invitation", # available to all users; access control is built into invitation logic
"atst.catch_all", # available to all users "atst.catch_all", # available to all users
"portfolios.portfolios", # the portfolios list is scoped to the user separately "portfolios.portfolios", # the portfolios list is scoped to the user separately
"portfolios.new_portfolio", # all users can create a portfolio
"portfolios.create_portfolio", # create a portfolio
] ]