Merge pull request #754 from dod-ccpo/delete-applications

Delete applications
This commit is contained in:
dandds 2019-04-15 16:18:18 -04:00 committed by GitHub
commit 80be332c22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 542 additions and 38 deletions

View File

@ -0,0 +1,35 @@
"""add soft delete to application and environment resources and roles
Revision ID: 014e4bceb947
Revises: 32438a35cfb5
Create Date: 2019-04-10 09:40:37.688157
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import expression
# revision identifiers, used by Alembic.
revision = '014e4bceb947'
down_revision = '32438a35cfb5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('application_roles', sa.Column('deleted', sa.Boolean(), server_default=expression.false(), nullable=False))
op.add_column('applications', sa.Column('deleted', sa.Boolean(), server_default=expression.false(), nullable=False))
op.add_column('environment_roles', sa.Column('deleted', sa.Boolean(), server_default=expression.false(), nullable=False))
op.add_column('environments', sa.Column('deleted', sa.Boolean(), server_default=expression.false(), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('environments', 'deleted')
op.drop_column('environment_roles', 'deleted')
op.drop_column('applications', 'deleted')
op.drop_column('application_roles', 'deleted')
# ### end Alembic commands ###

View File

@ -1,3 +1,5 @@
from sqlalchemy.orm.exc import NoResultFound
from atst.database import db from atst.database import db
from atst.domain.environments import Environments from atst.domain.environments import Environments
from atst.domain.exceptions import NotFoundError from atst.domain.exceptions import NotFoundError
@ -23,7 +25,9 @@ class Applications(object):
def get(cls, application_id): def get(cls, application_id):
try: try:
application = ( application = (
db.session.query(Application).filter_by(id=application_id).one() db.session.query(Application)
.filter_by(id=application_id, deleted=False)
.one()
) )
except NoResultFound: except NoResultFound:
raise NotFoundError("application") raise NotFoundError("application")
@ -63,3 +67,17 @@ class Applications(object):
db.session.commit() db.session.commit()
return application return application
@classmethod
def delete(cls, application):
for env in application.environments:
Environments.delete(env)
application.deleted = True
for role in application.roles:
role.deleted = True
db.session.add(role)
db.session.add(application)
db.session.commit()

View File

@ -8,6 +8,12 @@ class CloudProviderInterface:
""" """
raise NotImplementedError() raise NotImplementedError()
def delete_application(self, cloud_id): # pragma: no cover
"""Delete an application in the cloud with the provided cloud_id. Returns
True for success or raises an error.
"""
raise NotImplementedError()
def create_user(self, user): # pragma: no cover def create_user(self, user): # pragma: no cover
"""Create an account in the CSP for specified user. Returns the ID of """Create an account in the CSP for specified user. Returns the ID of
the created user. the created user.
@ -49,6 +55,11 @@ class MockCloudProvider(CloudProviderInterface):
cloud.""" cloud."""
return uuid4().hex return uuid4().hex
def delete_application(self, name):
"""Returns an id that represents what would be an application in the
cloud."""
return True
def create_user(self, user): def create_user(self, user):
"""Returns an id that represents what would be an user in the cloud.""" """Returns an id that represents what would be an user in the cloud."""
return uuid4().hex return uuid4().hex

View File

@ -51,7 +51,11 @@ class Environments(object):
@classmethod @classmethod
def get(cls, environment_id): def get(cls, environment_id):
try: try:
env = db.session.query(Environment).filter_by(id=environment_id).one() env = (
db.session.query(Environment)
.filter_by(id=environment_id, deleted=False)
.one()
)
except NoResultFound: except NoResultFound:
raise NotFoundError("environment") raise NotFoundError("environment")
@ -94,3 +98,19 @@ class Environments(object):
@classmethod @classmethod
def revoke_access(cls, environment, target_user): def revoke_access(cls, environment, target_user):
EnvironmentRoles.delete(environment.id, target_user.id) EnvironmentRoles.delete(environment.id, target_user.id)
@classmethod
def delete(cls, environment, commit=False):
environment.deleted = True
db.session.add(environment)
for role in environment.roles:
role.deleted = True
db.session.add(role)
if commit:
db.session.commit()
app.csp.cloud.delete_application(environment.cloud_id)
return environment

View File

@ -86,6 +86,7 @@ _PORTFOLIO_APP_MGMT_PERMISSION_SETS = [
"permissions": [ "permissions": [
Permissions.EDIT_APPLICATION, Permissions.EDIT_APPLICATION,
Permissions.CREATE_APPLICATION, Permissions.CREATE_APPLICATION,
Permissions.DELETE_APPLICATION,
Permissions.EDIT_APPLICATION_MEMBER, Permissions.EDIT_APPLICATION_MEMBER,
Permissions.CREATE_APPLICATION_MEMBER, Permissions.CREATE_APPLICATION_MEMBER,
Permissions.EDIT_ENVIRONMENT, Permissions.EDIT_ENVIRONMENT,

View File

@ -6,7 +6,9 @@ from atst.models.types import Id
from atst.models import mixins from atst.models import mixins
class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin): class Application(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
):
__tablename__ = "applications" __tablename__ = "applications"
id = Id() id = Id()
@ -15,7 +17,11 @@ class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False) portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False)
portfolio = relationship("Portfolio") portfolio = relationship("Portfolio")
environments = relationship("Environment", back_populates="application") environments = relationship(
"Environment",
back_populates="application",
primaryjoin="and_(Environment.application_id==Application.id, Environment.deleted==False)",
)
roles = relationship("ApplicationRole") roles = relationship("ApplicationRole")
@property @property
@ -34,3 +40,7 @@ class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
return "<Application(name='{}', description='{}', portfolio='{}', id='{}')>".format( return "<Application(name='{}', description='{}', portfolio='{}', id='{}')>".format(
self.name, self.description, self.portfolio.name, self.id self.name, self.description, self.portfolio.name, self.id
) )
@property
def history(self):
return self.get_changes()

View File

@ -26,7 +26,11 @@ application_roles_permission_sets = Table(
class ApplicationRole( class ApplicationRole(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.PermissionsMixin Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.PermissionsMixin,
mixins.DeletableMixin,
): ):
__tablename__ = "application_roles" __tablename__ = "application_roles"
@ -51,6 +55,10 @@ class ApplicationRole(
self.application.name, self.user_id, self.id, self.permissions self.application.name, self.user_id, self.id, self.permissions
) )
@property
def history(self):
return self.get_changes()
Index( Index(
"application_role_user_application", "application_role_user_application",

View File

@ -6,7 +6,9 @@ from atst.models.types import Id
from atst.models import mixins from atst.models import mixins
class Environment(Base, mixins.TimestampsMixin, mixins.AuditableMixin): class Environment(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
):
__tablename__ = "environments" __tablename__ = "environments"
id = Id() id = Id()
@ -44,3 +46,7 @@ class Environment(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
self.application.portfolio.name, self.application.portfolio.name,
self.id, self.id,
) )
@property
def history(self):
return self.get_changes()

View File

@ -13,7 +13,9 @@ class CSPRole(Enum):
TECHNICAL_READ = "Technical Read-only" TECHNICAL_READ = "Technical Read-only"
class EnvironmentRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): class EnvironmentRole(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
):
__tablename__ = "environment_roles" __tablename__ = "environment_roles"
id = types.Id() id = types.Id()

View File

@ -1,3 +1,4 @@
from .timestamps import TimestampsMixin from .timestamps import TimestampsMixin
from .auditable import AuditableMixin from .auditable import AuditableMixin
from .permissions import PermissionsMixin from .permissions import PermissionsMixin
from .deletable import DeletableMixin

View File

@ -0,0 +1,6 @@
from sqlalchemy import Column, Boolean
from sqlalchemy.sql import expression
class DeletableMixin(object):
deleted = Column(Boolean, nullable=False, server_default=expression.false())

View File

@ -8,6 +8,7 @@ class Permissions(object):
VIEW_APPLICATION = "view_application" VIEW_APPLICATION = "view_application"
EDIT_APPLICATION = "edit_application" EDIT_APPLICATION = "edit_application"
CREATE_APPLICATION = "create_application" CREATE_APPLICATION = "create_application"
DELETE_APPLICATION = "delete_application"
VIEW_APPLICATION_MEMBER = "view_application_member" VIEW_APPLICATION_MEMBER = "view_application_member"
EDIT_APPLICATION_MEMBER = "edit_application_member" EDIT_APPLICATION_MEMBER = "edit_application_member"
CREATE_APPLICATION_MEMBER = "create_application_member" CREATE_APPLICATION_MEMBER = "create_application_member"

View File

@ -16,7 +16,11 @@ class Portfolio(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
name = Column(String) name = Column(String)
defense_component = Column(String) # Department of Defense Component defense_component = Column(String) # Department of Defense Component
applications = relationship("Application", back_populates="portfolio") applications = relationship(
"Application",
back_populates="portfolio",
primaryjoin="and_(Application.portfolio_id==Portfolio.id, Application.deleted==False)",
)
roles = relationship("PortfolioRole") roles = relationship("PortfolioRole")
task_orders = relationship("TaskOrder") task_orders = relationship("TaskOrder")

View File

@ -25,7 +25,11 @@ class User(
permission_sets = relationship("PermissionSet", secondary=users_permission_sets) permission_sets = relationship("PermissionSet", secondary=users_permission_sets)
portfolio_roles = relationship("PortfolioRole", backref="user") portfolio_roles = relationship("PortfolioRole", backref="user")
application_roles = relationship("ApplicationRole", backref="user") application_roles = relationship(
"ApplicationRole",
backref="user",
primaryjoin="and_(ApplicationRole.user_id==User.id, ApplicationRole.deleted==False)",
)
email = Column(String, unique=True) email = Column(String, unique=True)
dod_id = Column(String, unique=True, nullable=False) dod_id = Column(String, unique=True, nullable=False)

View File

@ -15,6 +15,7 @@ from atst.domain.portfolios import Portfolios
from atst.forms.application import NewApplicationForm, ApplicationForm from atst.forms.application import NewApplicationForm, ApplicationForm
from atst.domain.authz.decorator import user_can_access_decorator as user_can from atst.domain.authz.decorator import user_can_access_decorator as user_can
from atst.models.permissions import Permissions from atst.models.permissions import Permissions
from atst.utils.flash import formatted_flash as flash
@portfolios_bp.route("/portfolios/<portfolio_id>/applications") @portfolios_bp.route("/portfolios/<portfolio_id>/applications")
@ -118,3 +119,18 @@ def access_environment(portfolio_id, environment_id):
token = app.csp.cloud.get_access_token(env_role) token = app.csp.cloud.get_access_token(env_role)
return redirect(url_for("atst.csp_environment_access", token=token)) return redirect(url_for("atst.csp_environment_access", token=token))
@portfolios_bp.route(
"/portfolios/<portfolio_id>/applications/<application_id>/delete", methods=["POST"]
)
@user_can(Permissions.DELETE_APPLICATION, message="delete application")
def delete_application(portfolio_id, application_id):
application = Applications.get(application_id)
Applications.delete(application)
flash("application_deleted", application_name=application.name)
return redirect(
url_for("portfolios.portfolio_applications", portfolio_id=portfolio_id)
)

View File

@ -148,6 +148,14 @@ MESSAGES = {
""", """,
"category": "success", "category": "success",
}, },
"application_deleted": {
"title_template": translate("flash.success"),
"message_template": """
{{ "flash.application.deleted" | translate({"application_name": application_name}) }}
<a href="#">Undo</a>.
""",
"category": "success",
},
} }

View File

@ -12,3 +12,4 @@ data:
RQ_QUEUES: atat-test RQ_QUEUES: atat-test
CRL_STORAGE_PROVIDER: CLOUDFILES CRL_STORAGE_PROVIDER: CLOUDFILES
LOG_JSON: "true" LOG_JSON: "true"
DEBUG: "false"

View File

@ -0,0 +1,15 @@
export default {
name: 'delete-confirmation',
data: function() {
return {
deleteText: '',
}
},
computed: {
valid: function() {
return this.deleteText.toLowerCase() === 'delete'
},
},
}

View File

@ -35,6 +35,7 @@ import DateSelector from './components/date_selector'
import SidenavToggler from './components/sidenav_toggler' import SidenavToggler from './components/sidenav_toggler'
import KoReview from './components/forms/ko_review' import KoReview from './components/forms/ko_review'
import BaseForm from './components/forms/base_form' import BaseForm from './components/forms/base_form'
import DeleteConfirmation from './components/delete_confirmation'
Vue.config.productionTip = false Vue.config.productionTip = false
@ -72,6 +73,7 @@ const app = new Vue({
SidenavToggler, SidenavToggler,
KoReview, KoReview,
BaseForm, BaseForm,
DeleteConfirmation,
}, },
mounted: function() { mounted: function() {

View File

@ -8,7 +8,7 @@ body {
.modal { .modal {
position: fixed; position: fixed;
z-index: 3; z-index: 6;
left: 0; left: 0;
right: 0; right: 0;
top: 0; top: 0;

View File

@ -213,12 +213,6 @@
border-top: 0; border-top: 0;
padding: 3 * $gap 2 * $gap; padding: 3 * $gap 2 * $gap;
.usa-button-secondary {
color: $color-red;
background-color: $color-red-lightest;
box-shadow: inset 0 0 0 1px $color-red;
}
.usa-button-disabled { .usa-button-disabled {
color: $color-gray-medium; color: $color-gray-medium;
background-color: $color-gray-lightest; background-color: $color-gray-lightest;
@ -271,10 +265,6 @@
height: 4rem; height: 4rem;
} }
.usa-button-danger {
background: $color-red;
}
select { select {
padding-left: 1.2rem padding-left: 1.2rem
} }

View File

@ -11,3 +11,23 @@ button,
opacity: 0.75; opacity: 0.75;
} }
} }
.button-danger {
background: $color-red;
&:hover {
background-color: $color-red-darkest;
}
}
.button-danger-outline, input[type="button"].button-danger-outline {
color: $color-red;
background-color: $color-red-lightest;
box-shadow: inset 0 0 0 1px $color-red;
&:hover {
color: white;
background-color: $color-red-darkest;
box-shadow: inset 0 0 0 1px $color-red-darkest;
}
}

View File

@ -21,26 +21,25 @@
} }
} %} } %}
<div class='alert alert--{{level}}' role='{{role}}' aria-live='{{levels.get(level).get('tone')}}'> <div class='usa-alert usa-alert-{{level}}' role='{{role}}' aria-live='{{levels.get(level).get('tone')}}'>
{{ Icon(levels.get(level).get('icon'), classes='alert__icon icon--large') }}
<div class='alert__content'> <div class='usa-alert-body'>
{% if vue_template %} {% if vue_template %}
<h2 class='alert__title' v-html='title'></h2> <h2 class='usa-alert-heading' v-html='title'></h2>
{% else %} {% else %}
<h2 class='alert__title'>{{title}}</h2> <h2 class='usa-alert-heading'>{{title}}</h2>
{% endif %} {% endif %}
{% if message %} {% if message %}
<div class='alert__message'>{{ message | safe }}</div> <div class='usa-alert-text'>{{ message | safe }}</div>
{% endif %} {% endif %}
{% if caller %} {% if caller %}
<div class='alert__message'>{{ caller() }}</div> <div class='usa-alert-text'>{{ caller() }}</div>
{% endif %} {% endif %}
{% if fragment %} {% if fragment %}
<div class='alert__message'> <div class='usa-alert-text'>
{% include fragment %} {% include fragment %}
</div> </div>
{% endif %} {% endif %}

View File

@ -12,7 +12,7 @@
{% elif ppoc %} {% elif ppoc %}
{% set archive_button_class = 'usa-button-disabled' %} {% set archive_button_class = 'usa-button-disabled' %}
{% else %} {% else %}
{% set archive_button_class = 'usa-button-secondary' %} {% set archive_button_class = 'button-danger-outline' %}
{% endif %} {% endif %}
</td> </td>

View File

@ -1,7 +1,9 @@
{% extends "portfolios/applications/base.html" %} {% extends "portfolios/applications/base.html" %}
{% from "components/alert.html" import Alert %}
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% from "components/modal.html" import Modal %}
{% set secondary_breadcrumb = 'portfolios.applications.existing_application_title' | translate({ "application_name": application.name }) %} {% set secondary_breadcrumb = 'portfolios.applications.existing_application_title' | translate({ "application_name": application.name }) %}
@ -14,6 +16,58 @@
<div class="panel__content"> <div class="panel__content">
{% include "fragments/applications/edit_application_form.html" %} {% include "fragments/applications/edit_application_form.html" %}
{{ form.csrf_token }}
<p>
{{ "fragments.edit_application_form.explain" | translate }}
</p>
<div class="form-row">
<div class="form-col form-col--half">
{{ TextInput(form.name) }}
</div>
<div class="form-col form-col--half">
{% if user_can(permissions.DELETE_APPLICATION) %}
<div class="usa-input">
<label for="delete-application">
<div class="usa-input__title">
&nbsp;
</div>
</label>
<input
id="delete-application"
type="button"
v-on:click="openModal('delete-application')"
class='usa-button button-danger-outline'
value="{{ "portfolios.applications.delete.button" | translate }}"
>
</div>
{% endif %}
</div>
</div>
<div class="form-row">
<div class="form-col form-col--half">
{{ TextInput(form.description, paragraph=True) }}
</div>
</div>
<div class="application-list-item">
<header>
<h2 class="block-list__title">{{ 'portfolios.applications.environments_heading' | translate }}</h2>
<p>
{{ 'portfolios.applications.environments_description' | translate }}
</p>
</header>
<ul>
{% for environment in application.environments %}
<li class="application-edit__env-list-item">
<div class="usa-input input--disabled">
<label>Environment Name</label>
<input type="text" disabled value="{{ environment.name }}" readonly />
</div>
</li>
{% endfor %}
</ul>
</div>
</div> </div>
<div class="panel__footer"> <div class="panel__footer">
@ -36,5 +90,42 @@
</div> </div>
</div> </div>
</div> </div>
{% if user_can(permissions.DELETE_APPLICATION) %}
{% call Modal(name="delete-application") %}
<h1>{{ "portfolios.applications.delete.header" | translate }}</h1>
{{
Alert(
title="portfolios.applications.delete.alert.title" | translate,
message="portfolios.applications.delete.alert.message" | translate,
level="warning"
)
}}
<delete-confirmation inline-template>
<div>
<div class="usa-input">
<label for="deleted-text">
<span class="usa-input__help">
{{ "common.delete_confirm" | translate }}
</span>
</label>
<input id="deleted-text" v-model="deleteText">
</div>
<div class="action-group">
<form method="POST" action="{{ url_for('portfolios.delete_application', portfolio_id=portfolio.id, application_id=application.id) }}">
{{ form.csrf_token }}
<button class="usa-button button-danger" v-bind:disabled="!valid">
{{ "portfolios.applications.delete.button" | translate }}
</button>
</form>
<div class="action-group">
<a v-on:click="deleteText = ''; $root.closeModal('delete-application')" class="action-group__action icon-link icon-link--default">{{ "common.cancel" | translate }}</a>
</div>
</div>
</div>
</delete-confirmation>
{% endcall %}
{% endif %}
{% endblock %} {% endblock %}

View File

@ -8,6 +8,7 @@
{% block portfolio_content %} {% block portfolio_content %}
<div class='portfolio-applications'> <div class='portfolio-applications'>
{% include "fragments/flash.html" %}
<div class='portfolio-applications__header row'> <div class='portfolio-applications__header row'>
<div class='portfolio-applications__header--title col col--grow'>Applications</div> <div class='portfolio-applications__header--title col col--grow'>Applications</div>
<div class='portfolio-applications__header--actions col'> <div class='portfolio-applications__header--actions col'>

View File

@ -1,6 +1,15 @@
import pytest
from atst.domain.applications import Applications from atst.domain.applications import Applications
from tests.factories import UserFactory, PortfolioFactory from atst.domain.exceptions import NotFoundError
from atst.domain.portfolios import Portfolios
from tests.factories import (
ApplicationFactory,
ApplicationRoleFactory,
UserFactory,
PortfolioFactory,
EnvironmentFactory,
)
def test_create_application_with_multiple_environments(): def test_create_application_with_multiple_environments():
@ -53,3 +62,30 @@ def test_can_only_update_name_and_description():
assert application.description == "a new application" assert application.description == "a new application"
assert len(application.environments) == 1 assert len(application.environments) == 1
assert application.environments[0].name == env_name assert application.environments[0].name == env_name
def test_get_excludes_deleted():
app = ApplicationFactory.create(deleted=True)
with pytest.raises(NotFoundError):
Applications.get(app.id)
def test_delete_application(session):
app = ApplicationFactory.create()
app_role = ApplicationRoleFactory.create(user=UserFactory.create(), application=app)
env1 = EnvironmentFactory.create(application=app)
env2 = EnvironmentFactory.create(application=app)
assert not app.deleted
assert not env1.deleted
assert not env2.deleted
assert not app_role.deleted
Applications.delete(app)
assert app.deleted
assert env1.deleted
assert env2.deleted
assert app_role.deleted
# changes are flushed
assert not session.dirty

View File

@ -1,8 +1,17 @@
import pytest
from atst.domain.environments import Environments from atst.domain.environments import Environments
from atst.domain.environment_roles import EnvironmentRoles from atst.domain.environment_roles import EnvironmentRoles
from atst.domain.portfolio_roles import PortfolioRoles from atst.domain.portfolio_roles import PortfolioRoles
from atst.domain.exceptions import NotFoundError
from tests.factories import ApplicationFactory, UserFactory, PortfolioFactory from tests.factories import (
ApplicationFactory,
UserFactory,
PortfolioFactory,
EnvironmentFactory,
EnvironmentRoleFactory,
)
def test_create_environments(): def test_create_environments():
@ -186,3 +195,29 @@ def test_get_scoped_environments(db):
application2_envs = Environments.for_user(developer, portfolio.applications[1]) application2_envs = Environments.for_user(developer, portfolio.applications[1])
assert [env.name for env in application2_envs] == ["application2 staging"] assert [env.name for env in application2_envs] == ["application2 staging"]
def test_get_excludes_deleted():
env = EnvironmentFactory.create(
deleted=True, application=ApplicationFactory.create()
)
with pytest.raises(NotFoundError):
Environments.get(env.id)
def test_delete_environment(session):
env = EnvironmentFactory.create(application=ApplicationFactory.create())
env_role = EnvironmentRoleFactory.create(user=UserFactory.create(), environment=env)
assert not env.deleted
assert not env_role.deleted
Environments.delete(env)
assert env.deleted
assert env_role.deleted
# did not flush
assert session.dirty
Environments.delete(env, commit=True)
assert env.deleted
assert env_role.deleted
# flushed the change
assert not session.dirty

View File

@ -105,7 +105,7 @@ class PortfolioFactory(Base):
class Meta: class Meta:
model = Portfolio model = Portfolio
name = factory.Faker("name") name = factory.Faker("domain_word")
defense_component = factory.LazyFunction(random_service_branch) defense_component = factory.LazyFunction(random_service_branch)
@classmethod @classmethod
@ -157,7 +157,7 @@ class ApplicationFactory(Base):
model = Application model = Application
portfolio = factory.SubFactory(PortfolioFactory) portfolio = factory.SubFactory(PortfolioFactory)
name = factory.Faker("name") name = factory.Faker("domain_word")
description = "A test application" description = "A test application"
@classmethod @classmethod
@ -192,6 +192,8 @@ class EnvironmentFactory(Base):
class Meta: class Meta:
model = Environment model = Environment
name = factory.Faker("domain_word")
@classmethod @classmethod
def _create(cls, model_class, *args, **kwargs): def _create(cls, model_class, *args, **kwargs):
with_members = kwargs.pop("members", []) with_members = kwargs.pop("members", [])

View File

@ -1,4 +1,10 @@
from tests.factories import ApplicationFactory, ApplicationRoleFactory from atst.models import AuditEvent
from tests.factories import (
ApplicationFactory,
ApplicationRoleFactory,
EnvironmentFactory,
)
def test_application_num_users(): def test_application_num_users():
@ -9,3 +15,26 @@ def test_application_num_users():
ApplicationRoleFactory.create(application=application) ApplicationRoleFactory.create(application=application)
assert application.num_users == 1 assert application.num_users == 1
def test_application_environments_excludes_deleted():
app = ApplicationFactory.create()
env = EnvironmentFactory.create(application=app)
EnvironmentFactory.create(application=app, deleted=True)
assert len(app.environments) == 1
assert app.environments[0].id == env.id
def test_audit_event_for_application_deletion(session):
app = ApplicationFactory.create()
app.deleted = True
session.add(app)
session.commit()
update_event = (
session.query(AuditEvent)
.filter(AuditEvent.resource_id == app.id, AuditEvent.action == "update")
.one()
)
assert update_event.changed_state.get("deleted")
assert update_event.changed_state["deleted"] == [False, True]

View File

@ -1,6 +1,13 @@
from atst.models import AuditEvent
from atst.domain.environments import Environments from atst.domain.environments import Environments
from atst.domain.applications import Applications from atst.domain.applications import Applications
from tests.factories import PortfolioFactory, UserFactory
from tests.factories import (
PortfolioFactory,
UserFactory,
EnvironmentFactory,
ApplicationFactory,
)
def test_add_user_to_environment(): def test_add_user_to_environment():
@ -15,3 +22,20 @@ def test_add_user_to_environment():
dev_environment = Environments.add_member(dev_environment, developer, "developer") dev_environment = Environments.add_member(dev_environment, developer, "developer")
assert developer in dev_environment.users assert developer in dev_environment.users
def test_audit_event_for_environment_deletion(session):
env = EnvironmentFactory.create(application=ApplicationFactory.create())
env.deleted = True
session.add(env)
session.commit()
update_event = (
session.query(AuditEvent)
.filter(AuditEvent.resource_id == env.id, AuditEvent.action == "update")
.one()
)
assert update_event.changed_state.get("deleted")
before, after = update_event.changed_state["deleted"]
assert not before
assert after

View File

@ -0,0 +1,9 @@
from tests.factories import ApplicationFactory, PortfolioFactory
def test_portfolio_applications_excludes_deleted():
portfolio = PortfolioFactory.create()
app = ApplicationFactory.create(portfolio=portfolio)
ApplicationFactory.create(portfolio=portfolio, deleted=True)
assert len(portfolio.applications) == 1
assert portfolio.applications[0].id == app.id

View File

@ -3,7 +3,7 @@ from sqlalchemy.exc import InternalError
from atst.models.user import User from atst.models.user import User
from tests.factories import UserFactory from tests.factories import UserFactory, ApplicationFactory, ApplicationRoleFactory
def test_profile_complete_with_all_info(): def test_profile_complete_with_all_info():
@ -24,3 +24,16 @@ def test_cannot_update_dod_id(session):
session.add(user) session.add(user)
with pytest.raises(InternalError): with pytest.raises(InternalError):
session.commit() session.commit()
def test_deleted_application_roles_are_ignored(session):
user = UserFactory.create()
app = ApplicationFactory.create()
app_role = ApplicationRoleFactory.create(user=user, application=app)
assert len(user.application_roles) == 1
app_role.deleted = True
session.add(app_role)
session.commit()
assert len(user.application_roles) == 0

View File

@ -1,4 +1,4 @@
from flask import url_for from flask import url_for, get_flashed_messages
from tests.factories import ( from tests.factories import (
UserFactory, UserFactory,
@ -290,3 +290,42 @@ def test_environment_access_with_no_role(client, user_session):
) )
) )
assert response.status_code == 404 assert response.status_code == 404
def test_delete_application(client, user_session):
user = UserFactory.create()
port = PortfolioFactory.create(
owner=user,
applications=[
{
"name": "mos eisley",
"environments": [
{"name": "bar"},
{"name": "booth"},
{"name": "band stage"},
],
}
],
)
application = port.applications[0]
user_session(user)
response = client.post(
url_for(
"portfolios.delete_application",
portfolio_id=port.id,
application_id=application.id,
)
)
# appropriate response and redirect
assert response.status_code == 302
assert response.location == url_for(
"portfolios.portfolio_applications", portfolio_id=port.id, _external=True
)
# appropriate flash message
message = get_flashed_messages()[0]
assert "deleted" in message["message"]
assert application.name in message["message"]
# app and envs are soft deleted
assert len(port.applications) == 0
assert len(application.environments) == 0

View File

@ -203,6 +203,44 @@ def test_portfolios_create_member_access(post_url_assert_status):
post_url_assert_status(rando, url, 404) post_url_assert_status(rando, url, 404)
# portfolios.delete_application
def test_portfolios_delete_application_access(post_url_assert_status, monkeypatch):
ccpo = UserFactory.create_ccpo()
owner = user_with()
app_admin = user_with()
rando = user_with()
portfolio = PortfolioFactory.create(
owner=owner, applications=[{"name": "mos eisley"}]
)
application = portfolio.applications[0]
ApplicationRoleFactory.create(
user=app_admin,
application=application,
permission_sets=PermissionSets.get_many(
[
PermissionSets.VIEW_APPLICATION,
PermissionSets.EDIT_APPLICATION_ENVIRONMENTS,
PermissionSets.EDIT_APPLICATION_TEAM,
PermissionSets.DELETE_APPLICATION_ENVIRONMENTS,
]
),
)
monkeypatch.setattr("atst.domain.applications.Applications.delete", lambda *a: True)
url = url_for(
"portfolios.delete_application",
portfolio_id=portfolio.id,
application_id=application.id,
)
post_url_assert_status(app_admin, url, 404)
post_url_assert_status(rando, url, 404)
post_url_assert_status(owner, url, 302)
post_url_assert_status(ccpo, url, 302)
# portfolios.edit_application # portfolios.edit_application
def test_portfolios_edit_application_access(get_url_assert_status): def test_portfolios_edit_application_access(get_url_assert_status):
ccpo = user_with(PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT) ccpo = user_with(PermissionSets.EDIT_PORTFOLIO_APPLICATION_MANAGEMENT)

View File

@ -28,6 +28,8 @@ flash:
delete_member_success: You have successfully deleted {member_name} from the portfolio. delete_member_success: You have successfully deleted {member_name} from the portfolio.
new_ppoc_title: Primary point of contact updated new_ppoc_title: Primary point of contact updated
new_ppoc_message: You have successfully added {ppoc_name} as the primary point of contact. You are no longer the PPoC. new_ppoc_message: You have successfully added {ppoc_name} as the primary point of contact. You are no longer the PPoC.
application:
deleted: You have successfully deleted the {application_name} application. To view the retained activity log, visit the portfolio administration page.
common: common:
back: Back back: Back
cancel: Cancel cancel: Cancel
@ -43,6 +45,7 @@ common:
contracting_officer: Contracting Officer contracting_officer: Contracting Officer
security_officer: Security Officer security_officer: Security Officer
contracting_officer_representative: Contracting Officer Representative contracting_officer_representative: Contracting Officer Representative
delete_confirm: "Please type the word DELETE to confirm:"
components: components:
modal: modal:
close: Close close: Close
@ -599,6 +602,12 @@ portfolios:
name: Name name: Name
members: Members members: Members
add_environment: Add New Environment add_environment: Add New Environment
delete:
button: Delete Application
header: Are you sure you want to delete this application?
alert:
title: Warning! This action is permanent.
message: You will lose access to this application and all of its reporting and metrics tools. The activity log will be retained.
admin: admin:
portfolio_members_title: Portfolio members portfolio_members_title: Portfolio members
portfolio_members_subheading: These members have different levels of access to the portfolio. portfolio_members_subheading: These members have different levels of access to the portfolio.