Portfolio archiving

This commit is contained in:
George Drummond 2019-06-06 09:18:35 -04:00
parent c6e8c8eb8a
commit cad43af455
No known key found for this signature in database
GPG Key ID: 296DD6077123BF17
14 changed files with 217 additions and 8 deletions

View File

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

View File

@ -1 +1,5 @@
from .portfolios import Portfolios, PortfolioError
from .portfolios import (
Portfolios,
PortfolioError,
PortfolioDeletionApplicationsExistError,
)

View File

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

View File

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

View File

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

View File

@ -74,3 +74,11 @@ def reports(portfolio_id):
expiration_date=expiration_date,
remaining_days=remaining_days,
)
@portfolios_bp.route("/portfolios/<portfolio_id>/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"))

View File

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

View File

@ -1,10 +1,10 @@
{% macro DeleteConfirmation(modal_id, delete_text, delete_action, form) %}
<delete-confirmation inline-template name="{{ modal_id }}" key="{{ modal_id }}">
{% macro DeleteConfirmation(modal_id, delete_text, delete_action, form, confirmation_text="delete") %}
<delete-confirmation inline-template name="{{ modal_id }}" key="{{ modal_id }}" confirmation-text="{{ confirmation_text }}">
<div>
<div class="usa-input">
<label for="{{ modal_id }}-deleted-text">
<span class="usa-input__help">
{{ "common.delete_confirm" | translate }}
{{ "common.delete_confirm" | translate({"word": confirmation_text.upper()}) }}
</span>
</label>
<input id="{{ modal_id }}-deleted-text" v-model="deleteText">

View File

@ -0,0 +1,42 @@
{% from "components/delete_confirmation.html" import DeleteConfirmation %}
{% from "components/alert.html" import Alert %}
{% from "components/modal.html" import Modal %}
<section id="primary-point-of-contact" class="panel">
<div class="panel__content">
<h2>{{ "fragments.delete_portfolio.title" | translate }}</h2>
<p>{{ "fragments.delete_portfolio.subtitle" | translate }}</p>
<div
class="usa-button-primary {% if applications_count == 0 %}button-danger{% else %}usa-button-disabled{% endif %}"
{% if applications_count == 0 %}v-on:click="openModal('delete_portfolio')"{% endif %}
>
{{ "common.delete" | translate }}
</div>
</div>
</section>
{% call Modal(name="delete_portfolio") %}
<h1>
{{ 'fragments.delete_portfolio.title' | translate }}
</h1>
{{
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 %}

View File

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

View File

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

View File

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

View File

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

View File

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