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.domain.environments import Environments
from atst.domain.exceptions import NotFoundError
@ -23,7 +25,9 @@ class Applications(object):
def get(cls, application_id):
try:
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:
raise NotFoundError("application")
@ -63,3 +67,17 @@ class Applications(object):
db.session.commit()
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()
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
"""Create an account in the CSP for specified user. Returns the ID of
the created user.
@ -49,6 +55,11 @@ class MockCloudProvider(CloudProviderInterface):
cloud."""
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):
"""Returns an id that represents what would be an user in the cloud."""
return uuid4().hex

View File

@ -51,7 +51,11 @@ class Environments(object):
@classmethod
def get(cls, environment_id):
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:
raise NotFoundError("environment")
@ -94,3 +98,19 @@ class Environments(object):
@classmethod
def revoke_access(cls, environment, target_user):
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.EDIT_APPLICATION,
Permissions.CREATE_APPLICATION,
Permissions.DELETE_APPLICATION,
Permissions.EDIT_APPLICATION_MEMBER,
Permissions.CREATE_APPLICATION_MEMBER,
Permissions.EDIT_ENVIRONMENT,

View File

@ -6,7 +6,9 @@ from atst.models.types import Id
from atst.models import mixins
class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
class Application(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
):
__tablename__ = "applications"
id = Id()
@ -15,7 +17,11 @@ class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False)
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")
@property
@ -34,3 +40,7 @@ class Application(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
return "<Application(name='{}', description='{}', portfolio='{}', id='{}')>".format(
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(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.PermissionsMixin
Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.PermissionsMixin,
mixins.DeletableMixin,
):
__tablename__ = "application_roles"
@ -51,6 +55,10 @@ class ApplicationRole(
self.application.name, self.user_id, self.id, self.permissions
)
@property
def history(self):
return self.get_changes()
Index(
"application_role_user_application",

View File

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

View File

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

View File

@ -1,3 +1,4 @@
from .timestamps import TimestampsMixin
from .auditable import AuditableMixin
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"
EDIT_APPLICATION = "edit_application"
CREATE_APPLICATION = "create_application"
DELETE_APPLICATION = "delete_application"
VIEW_APPLICATION_MEMBER = "view_application_member"
EDIT_APPLICATION_MEMBER = "edit_application_member"
CREATE_APPLICATION_MEMBER = "create_application_member"

View File

@ -16,7 +16,11 @@ class Portfolio(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
name = Column(String)
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")
task_orders = relationship("TaskOrder")

View File

@ -25,7 +25,11 @@ class User(
permission_sets = relationship("PermissionSet", secondary=users_permission_sets)
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)
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.domain.authz.decorator import user_can_access_decorator as user_can
from atst.models.permissions import Permissions
from atst.utils.flash import formatted_flash as flash
@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)
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",
},
"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
CRL_STORAGE_PROVIDER: CLOUDFILES
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 KoReview from './components/forms/ko_review'
import BaseForm from './components/forms/base_form'
import DeleteConfirmation from './components/delete_confirmation'
Vue.config.productionTip = false
@ -72,6 +73,7 @@ const app = new Vue({
SidenavToggler,
KoReview,
BaseForm,
DeleteConfirmation,
},
mounted: function() {

View File

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

View File

@ -213,12 +213,6 @@
border-top: 0;
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 {
color: $color-gray-medium;
background-color: $color-gray-lightest;
@ -271,10 +265,6 @@
height: 4rem;
}
.usa-button-danger {
background: $color-red;
}
select {
padding-left: 1.2rem
}

View File

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

View File

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

View File

@ -1,7 +1,9 @@
{% extends "portfolios/applications/base.html" %}
{% from "components/alert.html" import Alert %}
{% from "components/text_input.html" import TextInput %}
{% 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 }) %}
@ -14,6 +16,58 @@
<div class="panel__content">
{% 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 class="panel__footer">
@ -36,5 +90,42 @@
</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 %}

View File

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

View File

@ -1,6 +1,15 @@
import pytest
from atst.domain.applications import Applications
from tests.factories import UserFactory, PortfolioFactory
from atst.domain.portfolios import Portfolios
from atst.domain.exceptions import NotFoundError
from tests.factories import (
ApplicationFactory,
ApplicationRoleFactory,
UserFactory,
PortfolioFactory,
EnvironmentFactory,
)
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 len(application.environments) == 1
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.environment_roles import EnvironmentRoles
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():
@ -186,3 +195,29 @@ def test_get_scoped_environments(db):
application2_envs = Environments.for_user(developer, portfolio.applications[1])
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:
model = Portfolio
name = factory.Faker("name")
name = factory.Faker("domain_word")
defense_component = factory.LazyFunction(random_service_branch)
@classmethod
@ -157,7 +157,7 @@ class ApplicationFactory(Base):
model = Application
portfolio = factory.SubFactory(PortfolioFactory)
name = factory.Faker("name")
name = factory.Faker("domain_word")
description = "A test application"
@classmethod
@ -192,6 +192,8 @@ class EnvironmentFactory(Base):
class Meta:
model = Environment
name = factory.Faker("domain_word")
@classmethod
def _create(cls, model_class, *args, **kwargs):
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():
@ -9,3 +15,26 @@ def test_application_num_users():
ApplicationRoleFactory.create(application=application)
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.applications import Applications
from tests.factories import PortfolioFactory, UserFactory
from tests.factories import (
PortfolioFactory,
UserFactory,
EnvironmentFactory,
ApplicationFactory,
)
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")
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 tests.factories import UserFactory
from tests.factories import UserFactory, ApplicationFactory, ApplicationRoleFactory
def test_profile_complete_with_all_info():
@ -24,3 +24,16 @@ def test_cannot_update_dod_id(session):
session.add(user)
with pytest.raises(InternalError):
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 (
UserFactory,
@ -290,3 +290,42 @@ def test_environment_access_with_no_role(client, user_session):
)
)
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)
# 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
def test_portfolios_edit_application_access(get_url_assert_status):
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.
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.
application:
deleted: You have successfully deleted the {application_name} application. To view the retained activity log, visit the portfolio administration page.
common:
back: Back
cancel: Cancel
@ -43,6 +45,7 @@ common:
contracting_officer: Contracting Officer
security_officer: Security Officer
contracting_officer_representative: Contracting Officer Representative
delete_confirm: "Please type the word DELETE to confirm:"
components:
modal:
close: Close
@ -599,6 +602,12 @@ portfolios:
name: Name
members: Members
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:
portfolio_members_title: Portfolio members
portfolio_members_subheading: These members have different levels of access to the portfolio.