From cad43af4559f33c401d15cbe57d9525111257181 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Thu, 6 Jun 2019 09:18:35 -0400 Subject: [PATCH] Portfolio archiving --- .../b565531a1e82_portfolio_deletable.py | 28 +++++++++++++ atst/domain/portfolios/__init__.py | 6 ++- atst/domain/portfolios/portfolios.py | 20 +++++++++ atst/models/portfolio.py | 4 +- atst/routes/portfolios/admin.py | 1 + atst/routes/portfolios/index.py | 8 ++++ js/components/delete_confirmation.js | 9 +++- templates/components/delete_confirmation.html | 6 +-- templates/fragments/delete_portfolio.html | 42 +++++++++++++++++++ templates/portfolios/admin.html | 4 ++ tests/domain/test_portfolios.py | 28 ++++++++++++- tests/routes/portfolios/test_index.py | 33 +++++++++++++++ tests/test_access.py | 31 ++++++++++++++ translations.yaml | 5 ++- 14 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 alembic/versions/b565531a1e82_portfolio_deletable.py create mode 100644 templates/fragments/delete_portfolio.html diff --git a/alembic/versions/b565531a1e82_portfolio_deletable.py b/alembic/versions/b565531a1e82_portfolio_deletable.py new file mode 100644 index 00000000..ea7514ba --- /dev/null +++ b/alembic/versions/b565531a1e82_portfolio_deletable.py @@ -0,0 +1,28 @@ +"""Portfolio deletable + +Revision ID: b565531a1e82 +Revises: c19d6129cca1 +Create Date: 2019-06-06 09:16:08.803603 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b565531a1e82' +down_revision = 'c19d6129cca1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('portfolios', sa.Column('deleted', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('portfolios', 'deleted') + # ### end Alembic commands ### diff --git a/atst/domain/portfolios/__init__.py b/atst/domain/portfolios/__init__.py index cc5177ad..dd1dd918 100644 --- a/atst/domain/portfolios/__init__.py +++ b/atst/domain/portfolios/__init__.py @@ -1 +1,5 @@ -from .portfolios import Portfolios, PortfolioError +from .portfolios import ( + Portfolios, + PortfolioError, + PortfolioDeletionApplicationsExistError, +) diff --git a/atst/domain/portfolios/portfolios.py b/atst/domain/portfolios/portfolios.py index 9bf395a4..5fcd5320 100644 --- a/atst/domain/portfolios/portfolios.py +++ b/atst/domain/portfolios/portfolios.py @@ -1,3 +1,4 @@ +from atst.database import db from atst.domain.permission_sets import PermissionSets from atst.domain.authz import Authorization from atst.domain.portfolio_roles import PortfolioRoles @@ -13,6 +14,10 @@ class PortfolioError(Exception): pass +class PortfolioDeletionApplicationsExistError(Exception): + pass + + class Portfolios(object): @classmethod def create(cls, user, portfolio_attrs): @@ -32,6 +37,21 @@ class Portfolios(object): portfolio = PortfoliosQuery.get(portfolio_id) return ScopedPortfolio(user, portfolio) + @classmethod + def delete(cls, portfolio): + if len(portfolio.applications) != 0: + raise PortfolioDeletionApplicationsExistError() + + for portfolio_role in portfolio.roles: + PortfolioRoles.disable(portfolio_role) + + portfolio.deleted = True + + db.session.add(portfolio) + db.session.commit() + + return portfolio + @classmethod def get_for_update(cls, portfolio_id): portfolio = PortfoliosQuery.get(portfolio_id) diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index 446a77cf..61ec6375 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -10,7 +10,9 @@ from atst.utils import first_or_none from atst.database import db -class Portfolio(Base, mixins.TimestampsMixin, mixins.AuditableMixin): +class Portfolio( + Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin +): __tablename__ = "portfolios" id = types.Id() diff --git a/atst/routes/portfolios/admin.py b/atst/routes/portfolios/admin.py index 3fc79d96..910b4d09 100644 --- a/atst/routes/portfolios/admin.py +++ b/atst/routes/portfolios/admin.py @@ -90,6 +90,7 @@ def render_admin_page(portfolio, form=None): user=g.current_user, ppoc_id=members_data[0].get("member_id"), current_member_id=current_member_id, + applications_count=len(portfolio.applications), ) diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py index e4707a64..426b897a 100644 --- a/atst/routes/portfolios/index.py +++ b/atst/routes/portfolios/index.py @@ -74,3 +74,11 @@ def reports(portfolio_id): expiration_date=expiration_date, remaining_days=remaining_days, ) + + +@portfolios_bp.route("/portfolios//destroy", methods=["POST"]) +@user_can(Permissions.ARCHIVE_PORTFOLIO, message="archive portfolio") +def delete_portfolio(portfolio_id): + Portfolios.delete(portfolio=g.portfolio) + + return redirect(url_for("atst.home")) diff --git a/js/components/delete_confirmation.js b/js/components/delete_confirmation.js index cd710de3..92489c07 100644 --- a/js/components/delete_confirmation.js +++ b/js/components/delete_confirmation.js @@ -1,6 +1,13 @@ export default { name: 'delete-confirmation', + props: { + confirmationText: { + type: String, + default: 'delete', + }, + }, + data: function() { return { deleteText: '', @@ -9,7 +16,7 @@ export default { computed: { valid: function() { - return this.deleteText.toLowerCase() === 'delete' + return this.deleteText.toLowerCase() === this.confirmationText }, }, } diff --git a/templates/components/delete_confirmation.html b/templates/components/delete_confirmation.html index c694ce22..3c76ba22 100644 --- a/templates/components/delete_confirmation.html +++ b/templates/components/delete_confirmation.html @@ -1,10 +1,10 @@ -{% macro DeleteConfirmation(modal_id, delete_text, delete_action, form) %} - +{% macro DeleteConfirmation(modal_id, delete_text, delete_action, form, confirmation_text="delete") %} +
diff --git a/templates/fragments/delete_portfolio.html b/templates/fragments/delete_portfolio.html new file mode 100644 index 00000000..c318b031 --- /dev/null +++ b/templates/fragments/delete_portfolio.html @@ -0,0 +1,42 @@ +{% from "components/delete_confirmation.html" import DeleteConfirmation %} +{% from "components/alert.html" import Alert %} +{% from "components/modal.html" import Modal %} + +
+
+

{{ "fragments.delete_portfolio.title" | translate }}

+

{{ "fragments.delete_portfolio.subtitle" | translate }}

+ + +
+ {{ "common.delete" | translate }} +
+
+
+ +{% call Modal(name="delete_portfolio") %} +

+ {{ 'fragments.delete_portfolio.title' | translate }} +

+ + {{ + Alert( + level="warning", + title=('components.modal.destructive_title' | translate), + message=('components.modal.destructive_message' | translate({"resource": "portfolio"})), + ) + }} + + {{ + DeleteConfirmation( + modal_id='delete_portfolio', + delete_text='Deactivate', + delete_action=url_for('portfolios.delete_portfolio', portfolio_id=portfolio.id), + form=portfolio_form, + confirmation_text="deactivate", + ) + }} +{% endcall %} diff --git a/templates/portfolios/admin.html b/templates/portfolios/admin.html index c2539c2f..6d49033e 100644 --- a/templates/portfolios/admin.html +++ b/templates/portfolios/admin.html @@ -44,6 +44,10 @@ {% include "fragments/primary_point_of_contact.html" %} {% endif %} + {% if user_can(permissions.VIEW_PORTFOLIO_POC) %} + {% include "fragments/delete_portfolio.html" %} + {% endif %} + {% if user_can(permissions.VIEW_PORTFOLIO_USERS) %} {% include "fragments/admin/portfolio_members.html" %} {% endif %} diff --git a/tests/domain/test_portfolios.py b/tests/domain/test_portfolios.py index 64454c86..d779d07b 100644 --- a/tests/domain/test_portfolios.py +++ b/tests/domain/test_portfolios.py @@ -2,7 +2,11 @@ import pytest from uuid import uuid4 from atst.domain.exceptions import NotFoundError, UnauthorizedError -from atst.domain.portfolios import Portfolios, PortfolioError +from atst.domain.portfolios import ( + Portfolios, + PortfolioError, + PortfolioDeletionApplicationsExistError, +) from atst.domain.portfolio_roles import PortfolioRoles from atst.domain.applications import Applications from atst.domain.environments import Environments @@ -221,3 +225,25 @@ def test_invite(): assert invitation.role.portfolio == portfolio assert invitation.role.user is None assert invitation.dod_id == member_data["dod_id"] + + +def test_delete_success(): + portfolio = PortfolioFactory.create() + + assert not portfolio.deleted + + Portfolios.delete(portfolio=portfolio) + + assert portfolio.deleted + + +def test_delete_failure_with_applications(): + portfolio = PortfolioFactory.create() + application = ApplicationFactory.create(portfolio=portfolio) + + assert not portfolio.deleted + + with pytest.raises(PortfolioDeletionApplicationsExistError): + Portfolios.delete(portfolio=portfolio) + + assert not portfolio.deleted diff --git a/tests/routes/portfolios/test_index.py b/tests/routes/portfolios/test_index.py index 948c4301..e54e2a23 100644 --- a/tests/routes/portfolios/test_index.py +++ b/tests/routes/portfolios/test_index.py @@ -3,6 +3,7 @@ from flask import url_for from tests.factories import ( random_future_date, random_past_date, + ApplicationFactory, PortfolioFactory, TaskOrderFactory, UserFactory, @@ -109,3 +110,35 @@ def test_portfolio_reports_with_mock_portfolio(client, user_session): assert response.status_code == 200 assert portfolio.name in response.data.decode() assert "$251,626.00 Total spend to date" in response.data.decode() + + +def test_delete_portfolio_success(client, user_session): + portfolio = PortfolioFactory.create() + owner = portfolio.owner + user_session(owner) + + assert len(Portfolios.for_user(user=owner)) == 1 + + response = client.post( + url_for("portfolios.delete_portfolio", portfolio_id=portfolio.id) + ) + + assert response.status_code == 302 + assert url_for("atst.home") in response.location + assert len(Portfolios.for_user(user=owner)) == 0 + + +def test_delete_portfolio_failure(client, user_session): + portfolio = PortfolioFactory.create() + application = ApplicationFactory.create(portfolio=portfolio) + owner = portfolio.owner + user_session(owner) + + assert len(Portfolios.for_user(user=owner)) == 1 + + response = client.post( + url_for("portfolios.delete_portfolio", portfolio_id=portfolio.id) + ) + + assert response.status_code == 500 + assert len(Portfolios.for_user(user=owner)) == 1 diff --git a/tests/test_access.py b/tests/test_access.py index 6c2acc69..f3b588a9 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -570,3 +570,34 @@ def test_applications_application_team_access(get_url_assert_status): get_url_assert_status(ccpo, url, 200) get_url_assert_status(portfolio.owner, url, 200) get_url_assert_status(rando, url, 404) + + +def test_portfolio_delete_access(post_url_assert_status): + rando = UserFactory.create() + owner = UserFactory.create() + ccpo = UserFactory.create_ccpo() + + post_url_assert_status( + ccpo, + url_for( + "portfolios.delete_portfolio", portfolio_id=PortfolioFactory.create().id + ), + 302, + ) + + post_url_assert_status( + owner, + url_for( + "portfolios.delete_portfolio", + portfolio_id=PortfolioFactory.create(owner=owner).id, + ), + 302, + ) + + post_url_assert_status( + rando, + url_for( + "portfolios.delete_portfolio", portfolio_id=PortfolioFactory.create().id + ), + 404, + ) diff --git a/translations.yaml b/translations.yaml index 548d54a5..49317b2e 100644 --- a/translations.yaml +++ b/translations.yaml @@ -35,7 +35,7 @@ common: confirm: Confirm continue: Continue delete: Delete - delete_confirm: 'Please type the word DELETE to confirm:' + delete_confirm: 'Please type the word {word} to confirm:' edit: Edit hide: Hide manage: manage @@ -347,6 +347,9 @@ forms: phone_number_message: Please enter a valid 5 or 10 digit phone number. file_length: Your file may not exceed 50 MB. fragments: + delete_portfolio: + title: Delete Portfolio + subtitle: Portfolio deactivation is available only if there are no applications in the portfolio. edit_application_form: explain: AT-AT allows you to create multiple applications within a portfolio. Each application can then be broken down into its own customizable environments. new_application_title: Add a new application