Merge pull request #754 from dod-ccpo/delete-applications
Delete applications
This commit is contained in:
commit
80be332c22
@ -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 ###
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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()
|
||||||
|
@ -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",
|
||||||
|
@ -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()
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
6
atst/models/mixins/deletable.py
Normal file
6
atst/models/mixins/deletable.py
Normal 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())
|
@ -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"
|
||||||
|
@ -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")
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
)
|
||||||
|
@ -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",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
15
js/components/delete_confirmation.js
Normal file
15
js/components/delete_confirmation.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export default {
|
||||||
|
name: 'delete-confirmation',
|
||||||
|
|
||||||
|
data: function() {
|
||||||
|
return {
|
||||||
|
deleteText: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
valid: function() {
|
||||||
|
return this.deleteText.toLowerCase() === 'delete'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
@ -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() {
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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 %}
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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">
|
||||||
|
|
||||||
|
</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 %}
|
||||||
|
@ -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'>
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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", [])
|
||||||
|
@ -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]
|
||||||
|
@ -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
|
||||||
|
9
tests/models/test_portfolio.py
Normal file
9
tests/models/test_portfolio.py
Normal 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
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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.
|
||||||
|
Loading…
x
Reference in New Issue
Block a user