diff --git a/alembic/versions/988f8b23fbf6_portfolio_demographics.py b/alembic/versions/988f8b23fbf6_portfolio_demographics.py new file mode 100644 index 00000000..85fb242b --- /dev/null +++ b/alembic/versions/988f8b23fbf6_portfolio_demographics.py @@ -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 ### diff --git a/atst/domain/portfolios/portfolios.py b/atst/domain/portfolios/portfolios.py index db78f6f1..9bf395a4 100644 --- a/atst/domain/portfolios/portfolios.py +++ b/atst/domain/portfolios/portfolios.py @@ -15,10 +15,8 @@ class PortfolioError(Exception): class Portfolios(object): @classmethod - def create(cls, user, name, defense_component=None): - portfolio = PortfoliosQuery.create( - name=name, defense_component=defense_component - ) + def create(cls, user, portfolio_attrs): + portfolio = PortfoliosQuery.create(**portfolio_attrs) perms_sets = PermissionSets.get_many(PortfolioRoles.PORTFOLIO_PERMISSION_SETS) Portfolios._create_portfolio_role( user, diff --git a/atst/forms/portfolio.py b/atst/forms/portfolio.py index 16abdf03..6e807876 100644 --- a/atst/forms/portfolio.py +++ b/atst/forms/portfolio.py @@ -1,9 +1,24 @@ -from wtforms.fields import StringField -from wtforms.validators import Length +from wtforms.fields import ( + RadioField, + SelectField, + SelectMultipleField, + StringField, + TextAreaField, +) +from wtforms.validators import Length, Optional +from wtforms.widgets import ListWidget, CheckboxInput from .forms import BaseForm from atst.utils.localization import translate +from .data import ( + APPLICATION_COMPLEXITY, + APP_MIGRATION, + DEV_TEAM, + SERVICE_BRANCHES, + TEAM_EXPERIENCE, +) + class PortfolioForm(BaseForm): 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()], + ) diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index 7f2bbe84..bcf09c97 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -1,5 +1,6 @@ from sqlalchemy import Column, String from sqlalchemy.orm import relationship +from sqlalchemy.types import ARRAY from itertools import chain from atst.models import Base, mixins, types @@ -16,6 +17,15 @@ class Portfolio(Base, mixins.TimestampsMixin, mixins.AuditableMixin): name = Column(String) 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( "Application", back_populates="portfolio", diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py index f0758b93..e4707a64 100644 --- a/atst/routes/portfolios/index.py +++ b/atst/routes/portfolios/index.py @@ -1,8 +1,9 @@ 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 atst.forms.portfolio import PortfolioCreationForm from atst.domain.reports import Reports from atst.domain.portfolios import Portfolios from atst.models.permissions import Permissions @@ -19,6 +20,26 @@ def portfolios(): 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//reports") @user_can(Permissions.VIEW_PORTFOLIO_REPORTS, message="view portfolio reports") def reports(portfolio_id): diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index 9002f8e5..ad9b2826 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -242,9 +242,11 @@ class UpdateTaskOrderWorkflow(ShowTaskOrderWorkflow): pf = Portfolios.get(self.user, self.portfolio_id) else: pf = Portfolios.create( - self.user, - self.form.portfolio_name.data, - self.form.defense_component.data, + user=self.user, + portfolio_attrs={ + "name": self.form.portfolio_name.data, + "defense_component": self.form.defense_component.data, + }, ) self._task_order = TaskOrders.create(portfolio=pf, creator=self.user) TaskOrders.update(self.task_order, **self.task_order_form_data) diff --git a/script/seed_sample.py b/script/seed_sample.py index a0becb96..6c9d3f34 100644 --- a/script/seed_sample.py +++ b/script/seed_sample.py @@ -234,7 +234,8 @@ def create_demo_portfolio(name, data): return 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) @@ -259,14 +260,22 @@ def seed_db(): create_demo_portfolio("B-Wing", MockReportingProvider.REPORT_FIXTURE_MAP["B-Wing"]) 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_members_to_portfolio(tie_interceptor) add_applications_to_portfolio(tie_interceptor) 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_members_to_portfolio(tie_fighter) @@ -278,7 +287,11 @@ def seed_db(): ship = random.choice(ships) ships.remove(ship) 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_members_to_portfolio(portfolio) diff --git a/templates/home.html b/templates/home.html index 7f67bdf1..d757dee6 100644 --- a/templates/home.html +++ b/templates/home.html @@ -4,7 +4,7 @@
- + {{ "home.add_portfolio_button_text" | translate }} diff --git a/templates/portfolios/new.html b/templates/portfolios/new.html new file mode 100644 index 00000000..b68f3c96 --- /dev/null +++ b/templates/portfolios/new.html @@ -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 %} + +
+

New Portfolio Form

+ +
+ {{ form.csrf_token }} + + {{ TextInput(form.name) }} + {{ OptionsInput(form.defense_component) }} + {{ TextInput(form.description, paragraph=True) }} + +

{{ "task_orders.new.app_info.project_title" | translate }}

+ +
+ + {{ OptionsInput(form.app_migration) }} + + {{ OptionsInput(form.native_apps) }} +

{{ "forms.task_order.native_apps.not_sure_help" | translate }}

+ {{ MultiCheckboxInput(form.complexity, form.complexity_other) }} + +
+ +

{{ "task_orders.new.app_info.team_title" | translate }}

+

{{ "task_orders.new.app_info.subtitle" | translate }}

+ {{ MultiCheckboxInput(form.dev_team, form.dev_team_other) }} + {{ OptionsInput(form.team_experience) }} + +
+ +
+ {{ + SaveButton( + text=('common.save' | translate), + form="portfolio-create", + element="input", + ) + }} +
+
+
+
+ +{% endblock %} + diff --git a/tests/factories.py b/tests/factories.py index 917e3d98..341887c0 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -106,6 +106,13 @@ 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): with_applications = kwargs.pop("applications", []) diff --git a/tests/routes/portfolios/test_index.py b/tests/routes/portfolios/test_index.py index d8f985ec..368aa02e 100644 --- a/tests/routes/portfolios/test_index.py +++ b/tests/routes/portfolios/test_index.py @@ -8,6 +8,59 @@ from tests.factories import ( UserFactory, ) 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): diff --git a/tests/test_access.py b/tests/test_access.py index a551de9b..7318c819 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -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 "atst.catch_all", # available to all users "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 ]