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.permission_sets import PermissionSets
|
||||||
from atst.domain.authz import Authorization
|
from atst.domain.authz import Authorization
|
||||||
from atst.domain.portfolio_roles import PortfolioRoles
|
from atst.domain.portfolio_roles import PortfolioRoles
|
||||||
@ -13,6 +14,10 @@ class PortfolioError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioDeletionApplicationsExistError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Portfolios(object):
|
class Portfolios(object):
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, user, portfolio_attrs):
|
def create(cls, user, portfolio_attrs):
|
||||||
@ -32,6 +37,21 @@ class Portfolios(object):
|
|||||||
portfolio = PortfoliosQuery.get(portfolio_id)
|
portfolio = PortfoliosQuery.get(portfolio_id)
|
||||||
return ScopedPortfolio(user, portfolio)
|
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
|
@classmethod
|
||||||
def get_for_update(cls, portfolio_id):
|
def get_for_update(cls, portfolio_id):
|
||||||
portfolio = PortfoliosQuery.get(portfolio_id)
|
portfolio = PortfoliosQuery.get(portfolio_id)
|
||||||
|
@ -10,7 +10,9 @@ from atst.utils import first_or_none
|
|||||||
from atst.database import db
|
from atst.database import db
|
||||||
|
|
||||||
|
|
||||||
class Portfolio(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
|
class Portfolio(
|
||||||
|
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
|
||||||
|
):
|
||||||
__tablename__ = "portfolios"
|
__tablename__ = "portfolios"
|
||||||
|
|
||||||
id = types.Id()
|
id = types.Id()
|
||||||
|
@ -90,6 +90,7 @@ def render_admin_page(portfolio, form=None):
|
|||||||
user=g.current_user,
|
user=g.current_user,
|
||||||
ppoc_id=members_data[0].get("member_id"),
|
ppoc_id=members_data[0].get("member_id"),
|
||||||
current_member_id=current_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,
|
expiration_date=expiration_date,
|
||||||
remaining_days=remaining_days,
|
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 {
|
export default {
|
||||||
name: 'delete-confirmation',
|
name: 'delete-confirmation',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
confirmationText: {
|
||||||
|
type: String,
|
||||||
|
default: 'delete',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
data: function() {
|
data: function() {
|
||||||
return {
|
return {
|
||||||
deleteText: '',
|
deleteText: '',
|
||||||
@ -9,7 +16,7 @@ export default {
|
|||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
valid: function() {
|
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) %}
|
{% macro DeleteConfirmation(modal_id, delete_text, delete_action, form, confirmation_text="delete") %}
|
||||||
<delete-confirmation inline-template name="{{ modal_id }}" key="{{ modal_id }}">
|
<delete-confirmation inline-template name="{{ modal_id }}" key="{{ modal_id }}" confirmation-text="{{ confirmation_text }}">
|
||||||
<div>
|
<div>
|
||||||
<div class="usa-input">
|
<div class="usa-input">
|
||||||
<label for="{{ modal_id }}-deleted-text">
|
<label for="{{ modal_id }}-deleted-text">
|
||||||
<span class="usa-input__help">
|
<span class="usa-input__help">
|
||||||
{{ "common.delete_confirm" | translate }}
|
{{ "common.delete_confirm" | translate({"word": confirmation_text.upper()}) }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input id="{{ modal_id }}-deleted-text" v-model="deleteText">
|
<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" %}
|
{% include "fragments/primary_point_of_contact.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if user_can(permissions.VIEW_PORTFOLIO_POC) %}
|
||||||
|
{% include "fragments/delete_portfolio.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if user_can(permissions.VIEW_PORTFOLIO_USERS) %}
|
{% if user_can(permissions.VIEW_PORTFOLIO_USERS) %}
|
||||||
{% include "fragments/admin/portfolio_members.html" %}
|
{% include "fragments/admin/portfolio_members.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -2,7 +2,11 @@ import pytest
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from atst.domain.exceptions import NotFoundError, UnauthorizedError
|
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.portfolio_roles import PortfolioRoles
|
||||||
from atst.domain.applications import Applications
|
from atst.domain.applications import Applications
|
||||||
from atst.domain.environments import Environments
|
from atst.domain.environments import Environments
|
||||||
@ -221,3 +225,25 @@ def test_invite():
|
|||||||
assert invitation.role.portfolio == portfolio
|
assert invitation.role.portfolio == portfolio
|
||||||
assert invitation.role.user is None
|
assert invitation.role.user is None
|
||||||
assert invitation.dod_id == member_data["dod_id"]
|
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 (
|
from tests.factories import (
|
||||||
random_future_date,
|
random_future_date,
|
||||||
random_past_date,
|
random_past_date,
|
||||||
|
ApplicationFactory,
|
||||||
PortfolioFactory,
|
PortfolioFactory,
|
||||||
TaskOrderFactory,
|
TaskOrderFactory,
|
||||||
UserFactory,
|
UserFactory,
|
||||||
@ -109,3 +110,35 @@ def test_portfolio_reports_with_mock_portfolio(client, user_session):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert portfolio.name in response.data.decode()
|
assert portfolio.name in response.data.decode()
|
||||||
assert "$251,626.00 Total spend to date" 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(ccpo, url, 200)
|
||||||
get_url_assert_status(portfolio.owner, url, 200)
|
get_url_assert_status(portfolio.owner, url, 200)
|
||||||
get_url_assert_status(rando, url, 404)
|
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
|
confirm: Confirm
|
||||||
continue: Continue
|
continue: Continue
|
||||||
delete: Delete
|
delete: Delete
|
||||||
delete_confirm: 'Please type the word DELETE to confirm:'
|
delete_confirm: 'Please type the word {word} to confirm:'
|
||||||
edit: Edit
|
edit: Edit
|
||||||
hide: Hide
|
hide: Hide
|
||||||
manage: manage
|
manage: manage
|
||||||
@ -347,6 +347,9 @@ forms:
|
|||||||
phone_number_message: Please enter a valid 5 or 10 digit phone number.
|
phone_number_message: Please enter a valid 5 or 10 digit phone number.
|
||||||
file_length: Your file may not exceed 50 MB.
|
file_length: Your file may not exceed 50 MB.
|
||||||
fragments:
|
fragments:
|
||||||
|
delete_portfolio:
|
||||||
|
title: Delete Portfolio
|
||||||
|
subtitle: Portfolio deactivation is available only if there are no applications in the portfolio.
|
||||||
edit_application_form:
|
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.
|
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
|
new_application_title: Add a new application
|
||||||
|
Loading…
x
Reference in New Issue
Block a user