diff --git a/atst/domain/environment_roles.py b/atst/domain/environment_roles.py index f2ed2988..add92091 100644 --- a/atst/domain/environment_roles.py +++ b/atst/domain/environment_roles.py @@ -29,6 +29,7 @@ class EnvironmentRoles(object): EnvironmentRole.application_role_id == application_role_id, EnvironmentRole.environment_id == environment_id, EnvironmentRole.deleted == False, + EnvironmentRole.status != EnvironmentRole.Status.DISABLED, ) .one_or_none() ) @@ -57,6 +58,14 @@ class EnvironmentRoles(object): ) return existing_env_role + @classmethod + def _update_status(cls, environment_role, new_status): + environment_role.status = new_status + db.session.add(environment_role) + db.session.commit() + + return environment_role + @classmethod def delete(cls, application_role_id, environment_id): existing_env_role = EnvironmentRoles.get(application_role_id, environment_id) @@ -104,3 +113,15 @@ class EnvironmentRoles(object): db.session.commit() return environment_role + + @classmethod + def get_for_update(cls, application_role_id, environment_id): + existing_env_role = ( + db.session.query(EnvironmentRole) + .filter( + EnvironmentRole.application_role_id == application_role_id, + EnvironmentRole.environment_id == environment_id, + ) + .one_or_none() + ) + return existing_env_role diff --git a/atst/domain/environments.py b/atst/domain/environments.py index 1526f6dc..2114ce21 100644 --- a/atst/domain/environments.py +++ b/atst/domain/environments.py @@ -4,10 +4,17 @@ from typing import List from uuid import UUID from atst.database import db -from atst.models import Environment, Application, Portfolio, TaskOrder, CLIN +from atst.models import ( + Environment, + Application, + Portfolio, + TaskOrder, + CLIN, + EnvironmentRole, +) from atst.domain.environment_roles import EnvironmentRoles -from .exceptions import NotFoundError +from .exceptions import NotFoundError, DisabledError class Environments(object): @@ -50,29 +57,31 @@ class Environments(object): @classmethod def update_env_role(cls, environment, application_role, new_role): - updated = False + env_role = EnvironmentRoles.get_for_update(application_role.id, environment.id) + if env_role and ( + env_role.status == EnvironmentRole.Status.DISABLED or env_role.deleted + ): + raise DisabledError("environment_role", env_role.id) - if new_role is None: - updated = EnvironmentRoles.delete(application_role.id, environment.id) - else: - env_role = EnvironmentRoles.get(application_role.id, environment.id) - if env_role and env_role.role != new_role: - env_role.role = new_role - updated = True - db.session.add(env_role) - elif not env_role: - env_role = EnvironmentRoles.create( - application_role=application_role, - environment=environment, - role=new_role, - ) - updated = True - db.session.add(env_role) + if ( + env_role + and env_role.role != new_role + and env_role.status != EnvironmentRole.Status.DISABLED + ): + env_role.role = new_role + db.session.add(env_role) + elif not env_role and new_role: + env_role = EnvironmentRoles.create( + application_role=application_role, + environment=environment, + role=new_role, + ) + db.session.add(env_role) - if updated: - db.session.commit() + if env_role and not new_role: + EnvironmentRoles.disable(env_role.id) - return updated + db.session.commit() @classmethod def revoke_access(cls, environment, target_user): diff --git a/atst/domain/exceptions.py b/atst/domain/exceptions.py index a89aa986..ab60ad61 100644 --- a/atst/domain/exceptions.py +++ b/atst/domain/exceptions.py @@ -53,3 +53,13 @@ class ClaimFailedException(Exception): f"Could not acquire claim for {resource.__class__.__name__} {resource.id}." ) super().__init__(message) + + +class DisabledError(Exception): + def __init__(self, resource_name, resource_id=None): + self.resource_name = resource_name + self.resource_id = resource_id + + @property + def message(self): + return f"Cannot update disabled {self.resource_name} {self.resource_id}." diff --git a/atst/forms/application_member.py b/atst/forms/application_member.py index ce2b6edd..ec873f77 100644 --- a/atst/forms/application_member.py +++ b/atst/forms/application_member.py @@ -16,8 +16,9 @@ class EnvironmentForm(Form): environment_name, choices=ENV_ROLES, default=NO_ACCESS, - filters=[lambda x: None if x == "None" else x], + filters=[lambda x: NO_ACCESS if x == "None" else x], ) + disabled = BooleanField("Revoke Access", default=False) @property def data(self): diff --git a/atst/models/application_role.py b/atst/models/application_role.py index f0e20be1..9468896c 100644 --- a/atst/models/application_role.py +++ b/atst/models/application_role.py @@ -1,5 +1,5 @@ from enum import Enum -from sqlalchemy import and_, Index, ForeignKey, Column, Enum as SQLAEnum, Table +from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum, Table from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from sqlalchemy.event import listen @@ -8,7 +8,6 @@ from atst.utils import first_or_none from atst.models.base import Base import atst.models.mixins as mixins import atst.models.types as types -from atst.models.environment_role import EnvironmentRole from atst.models.mixins.auditable import record_permission_sets_updates @@ -55,9 +54,7 @@ class ApplicationRole( environment_roles = relationship( "EnvironmentRole", - primaryjoin=and_( - EnvironmentRole.application_role_id == id, EnvironmentRole.deleted == False - ), + primaryjoin="and_(EnvironmentRole.application_role_id == ApplicationRole.id, EnvironmentRole.deleted == False)", ) @property diff --git a/atst/routes/applications/settings.py b/atst/routes/applications/settings.py index 05d906ab..16d227ed 100644 --- a/atst/routes/applications/settings.py +++ b/atst/routes/applications/settings.py @@ -14,6 +14,7 @@ from atst.forms.application import NameAndDescriptionForm, EditEnvironmentForm from atst.forms.data import ENV_ROLE_NO_ACCESS as NO_ACCESS from atst.forms.member import NewForm as MemberForm from atst.domain.authz.decorator import user_can_access_decorator as user_can +from atst.models.environment_role import EnvironmentRole from atst.models.permissions import Permissions from atst.domain.permission_sets import PermissionSets from atst.utils.flash import formatted_flash as flash @@ -31,7 +32,14 @@ def get_environments_obj_for_app(application): "edit_form": EditEnvironmentForm(obj=env), "member_count": len(env.roles), "members": sorted( - [env_role.application_role.user_name for env_role in env.roles] + [ + { + "user_name": env_role.application_role.user_name, + "status": env_role.status.value, + } + for env_role in env.roles + ], + key=lambda env_role: env_role["user_name"], ), } for env in application.environments @@ -77,11 +85,14 @@ def filter_env_roles_form_data(member, environments): "environment_id": str(env.id), "environment_name": env.name, "role": NO_ACCESS, + "disabled": False, } env_roles_set = set(env.roles).intersection(set(member.environment_roles)) + if len(env_roles_set) == 1: (env_role,) = env_roles_set env_data["role"] = env_role.role + env_data["disabled"] = env_role.status == EnvironmentRole.Status.DISABLED env_roles_form_data.append(env_data) @@ -373,14 +384,21 @@ def remove_member(application_id, application_role_id): @user_can(Permissions.EDIT_APPLICATION_MEMBER, message="update application member") def update_member(application_id, application_role_id): app_role = ApplicationRoles.get_by_id(application_role_id) - form = UpdateMemberForm(http_request.form) + application = Applications.get(application_id) + existing_env_roles_data = filter_env_roles_form_data( + app_role, application.environments + ) + form = UpdateMemberForm( + formdata=http_request.form, environment_roles=existing_env_roles_data + ) if form.validate(): ApplicationRoles.update_permission_sets(app_role, form.data["permission_sets"]) for env_role in form.environment_roles: environment = Environments.get(env_role.environment_id.data) - Environments.update_env_role(environment, app_role, env_role.data["role"]) + new_role = None if env_role.disabled.data else env_role.data["role"] + Environments.update_env_role(environment, app_role, new_role) flash("application_member_updated", user_name=app_role.user_name) else: diff --git a/static/icons/avatar.svg b/static/icons/avatar.svg index a3b68837..23737bfd 100644 --- a/static/icons/avatar.svg +++ b/static/icons/avatar.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/styles/sections/_application_edit.scss b/styles/sections/_application_edit.scss index 1c1c395d..8ff79b7b 100644 --- a/styles/sections/_application_edit.scss +++ b/styles/sections/_application_edit.scss @@ -91,16 +91,52 @@ margin-bottom: 8 * $gap; text-align: left; - .usa-input { - margin: $gap 0 $gap 0; + .form-row { + margin: $gap * 2; + } - .usa-input__title-inline { - margin-top: $gap; - margin-left: $gap; + .usa-alert { + margin: -0.5em 0; + padding: $gap; + background-image: unset; + + &-body { + padding: 0; + width: 100%; + display: unset; + } + } + + .usa-input { + &__title-inline { + font-weight: $font-bold; } - .form-row { - margin: 0; + &__help { + margin-bottom: 0; + } + + &.env-role__no-access { + .usa-input__title-inline, + .usa-input__help { + color: $color-gray; + } + } + + &__choices { + &.revoke-button label { + padding: 1rem 2rem; + + &::before { + display: none; + } + + &.link { + color: $color-blue; + text-decoration: underline; + text-align: right; + } + } } } @@ -112,15 +148,69 @@ margin: 0.5em 0; } } - - .form-row { - margin: 0; - } } -.environment-name--gray { - font-weight: $font-normal; - color: $color-gray-medium; +.environment-role { + padding: ($gap * 2) 0; + + h4 { + margin-bottom: $gap / 4; + } + + &__users { + background-color: $color-gray-lightest; + padding: ($gap * 1.2) ($gap * 0.6); + font-size: $small-font-size; + display: flex; + flex-wrap: wrap; + + .environment-role__user { + background-color: $color-white; + border-radius: 0.5rem; + padding: ($gap / 2) $gap; + border: solid 2px $color-blue; + margin: $gap; + white-space: nowrap; + width: 20rem; + position: relative; + height: 3.6rem; + + &-field { + position: absolute; + background-color: $color-white; + margin-top: $gap * 2; + padding: $gap; + left: -0.1rem; + border: solid 1px $color-gray-light; + width: 20rem; + z-index: 3; + + .usa-input { + margin: 0; + + li { + background-color: $color-white; + border: none; + } + } + } + + &.unassigned { + border: solid 1px $color-gray-light; + } + + .icon-link { + padding: 0; + } + } + + .environment-role__no-user { + margin: $gap; + padding: ($gap / 2) $gap; + font-weight: $font-normal; + height: 3.6rem; + } + } } .application-list-item { diff --git a/templates/applications/fragments/environments.html b/templates/applications/fragments/environments.html index 923d41a9..6ff63642 100644 --- a/templates/applications/fragments/environments.html +++ b/templates/applications/fragments/environments.html @@ -64,8 +64,9 @@ {% call ToggleSection(section_name="members") %}
+ {{ role }} +
++ {{ "portfolios.applications.members.form.env_access.suspended" | translate }} +
+ {%- endif %} + {{ sub_form.environment_id() }} +{{ "portfolios.applications.members.form.app_perms.description" | translate | safe}}
@@ -23,32 +104,15 @@{{ "portfolios.applications.members.form.env_access.description" | translate | safe }}
++ {% if not new -%} + {{ "portfolios.applications.members.form.env_access.edit_description" | translate }} + {%- endif %} + {{ "portfolios.applications.members.form.env_access.description" | translate | safe }} +