Portfolio archiving
This commit is contained in:
parent
c6e8c8eb8a
commit
cad43af455
28
alembic/versions/b565531a1e82_portfolio_deletable.py
Normal file
28
alembic/versions/b565531a1e82_portfolio_deletable.py
Normal 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 ###
|
@ -1 +1,5 @@
|
||||
from .portfolios import Portfolios, PortfolioError
|
||||
from .portfolios import (
|
||||
Portfolios,
|
||||
PortfolioError,
|
||||
PortfolioDeletionApplicationsExistError,
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
@ -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"))
|
||||
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -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">
|
||||
|
42
templates/fragments/delete_portfolio.html
Normal file
42
templates/fragments/delete_portfolio.html
Normal 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 %}
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user