Add New Portfolio Workflow
This commit is contained in:
parent
ad5d704fa8
commit
f7562714cb
42
alembic/versions/988f8b23fbf6_portfolio_demographics.py
Normal file
42
alembic/versions/988f8b23fbf6_portfolio_demographics.py
Normal 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 ###
|
@ -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,
|
||||||
|
@ -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()],
|
||||||
|
)
|
||||||
|
@ -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",
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
53
templates/portfolios/new.html
Normal file
53
templates/portfolios/new.html
Normal 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 %}
|
||||||
|
|
@ -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", [])
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user