diff --git a/atst/routes/applications/__init__.py b/atst/routes/applications/__init__.py index 8ec1e4cf..6a98e188 100644 --- a/atst/routes/applications/__init__.py +++ b/atst/routes/applications/__init__.py @@ -5,7 +5,6 @@ applications_bp = Blueprint("applications", __name__) from . import index from . import new from . import settings -from . import team from . import invitations from atst.domain.environment_roles import EnvironmentRoles from atst.domain.exceptions import UnauthorizedError diff --git a/atst/routes/applications/settings.py b/atst/routes/applications/settings.py index 9e748651..e586e317 100644 --- a/atst/routes/applications/settings.py +++ b/atst/routes/applications/settings.py @@ -1,17 +1,24 @@ -from flask import redirect, render_template, request as http_request, url_for +from flask import redirect, render_template, request as http_request, url_for, g from . import applications_bp +from atst.domain.exceptions import AlreadyExistsError from atst.domain.environments import Environments from atst.domain.applications import Applications +from atst.domain.application_roles import ApplicationRoles from atst.domain.audit_log import AuditLog from atst.domain.common import Paginator +from atst.domain.environment_roles import EnvironmentRoles from atst.forms.app_settings import AppEnvRolesForm from atst.forms.application import ApplicationForm, EditEnvironmentForm +from atst.forms.application_member import NewForm as NewMemberForm from atst.forms.data import ENV_ROLE_NO_ACCESS as NO_ACCESS from atst.domain.authz.decorator import user_can_access_decorator as user_can from atst.models.environment_role import CSPRole from atst.models.permissions import Permissions +from atst.domain.permission_sets import PermissionSets from atst.utils.flash import formatted_flash as flash +from atst.utils.localization import translate +from atst.jobs import send_mail def get_environments_obj_for_app(application): @@ -79,12 +86,65 @@ def data_for_app_env_roles_form(application): return {"envs": nested_data} +def get_form_permission_value(member, edit_perm_set): + if member.has_permission_set(edit_perm_set): + return edit_perm_set + else: + return PermissionSets.VIEW_APPLICATION + + +def get_members_data(application): + members_data = [] + for member in application.members: + permission_sets = { + "perms_team_mgmt": get_form_permission_value( + member, PermissionSets.EDIT_APPLICATION_TEAM + ), + "perms_env_mgmt": get_form_permission_value( + member, PermissionSets.EDIT_APPLICATION_ENVIRONMENTS + ), + "perms_del_env": get_form_permission_value( + member, PermissionSets.DELETE_APPLICATION_ENVIRONMENTS + ), + } + roles = EnvironmentRoles.get_for_application_member(member.id) + environment_roles = [ + { + "environment_id": str(role.environment.id), + "environment_name": role.environment.name, + "role": role.role, + } + for role in roles + ] + members_data.append( + { + "role_id": member.id, + "user_name": member.user_name, + "permission_sets": permission_sets, + "environment_roles": environment_roles, + } + ) + + return members_data + + +def get_new_member_form(application): + env_roles = [ + {"environment_id": e.id, "environment_name": e.name} + for e in application.environments + ] + + return NewMemberForm(data={"environment_roles": env_roles}) + + def render_settings_page(application, **kwargs): environments_obj = get_environments_obj_for_app(application=application) members_form = AppEnvRolesForm(data=data_for_app_env_roles_form(application)) new_env_form = EditEnvironmentForm() pagination_opts = Paginator.get_pagination_opts(http_request) audit_events = AuditLog.get_application_events(application, pagination_opts) + new_member_form = get_new_member_form(application) + members = get_members_data(application) if "application_form" not in kwargs: kwargs["application_form"] = ApplicationForm( @@ -98,10 +158,23 @@ def render_settings_page(application, **kwargs): members_form=members_form, new_env_form=new_env_form, audit_events=audit_events, + new_member_form=new_member_form, + members=members, **kwargs, ) +def send_application_invitation(invitee_email, inviter_name, token): + body = render_template( + "emails/application/invitation.txt", owner=inviter_name, token=token + ) + send_mail.delay( + [invitee_email], + translate("email.application_invite", {"inviter_name": inviter_name}), + body, + ) + + @applications_bp.route("/applications//settings") @user_can(Permissions.VIEW_APPLICATION, message="view application edit form") def settings(application_id): @@ -264,3 +337,72 @@ def delete_environment(environment_id): fragment="application-environments", ) ) + + +@applications_bp.route("/application//members/new", methods=["POST"]) +@user_can( + Permissions.CREATE_APPLICATION_MEMBER, message="create new application member" +) +def create_member(application_id): + application = Applications.get(application_id) + form = NewMemberForm(http_request.form) + + if form.validate(): + try: + invite = Applications.invite( + application=application, + inviter=g.current_user, + user_data=form.user_data.data, + permission_sets_names=form.permission_sets.data, + environment_roles_data=form.environment_roles.data, + ) + + send_application_invitation( + invitee_email=invite.email, + inviter_name=g.current_user.full_name, + token=invite.token, + ) + + flash("new_application_member", user_name=invite.user_name) + + except AlreadyExistsError: + return render_template( + "error.html", message="There was an error processing your request." + ) + else: + pass + # TODO: flash error message + + return redirect( + url_for( + "applications.settings", + application_id=application_id, + fragment="application-members", + _anchor="application-members", + ) + ) + + +@applications_bp.route( + "/applications//members//delete", + methods=["POST"], +) +@user_can(Permissions.DELETE_APPLICATION_MEMBER, message="remove application member") +def remove_member(application_id, application_role_id): + application_role = ApplicationRoles.get_by_id(application_role_id) + Applications.remove_member(application_role) + + flash( + "application_member_removed", + user_name=application_role.user_name, + application_name=g.application.name, + ) + + return redirect( + url_for( + "applications.settings", + _anchor="application-members", + application_id=g.application.id, + fragment="application-members", + ) + ) diff --git a/atst/routes/applications/team.py b/atst/routes/applications/team.py deleted file mode 100644 index be151916..00000000 --- a/atst/routes/applications/team.py +++ /dev/null @@ -1,205 +0,0 @@ -from flask import render_template, request as http_request, g, url_for, redirect - - -from . import applications_bp -from atst.domain.applications import Applications -from atst.domain.application_roles import ApplicationRoles -from atst.domain.authz.decorator import user_can_access_decorator as user_can -from atst.domain.environment_roles import EnvironmentRoles -from atst.domain.environments import Environments -from atst.domain.exceptions import AlreadyExistsError -from atst.domain.permission_sets import PermissionSets -from atst.forms.application_member import NewForm as NewMemberForm -from atst.forms.team import TeamForm -from atst.models import Permissions -from atst.utils.flash import formatted_flash as flash -from atst.utils.localization import translate -from atst.jobs import send_mail - - -def get_form_permission_value(member, edit_perm_set): - if member.has_permission_set(edit_perm_set): - return edit_perm_set - else: - return PermissionSets.VIEW_APPLICATION - - -def get_team_form(application): - team_data = [] - for member in application.members: - permission_sets = { - "perms_team_mgmt": get_form_permission_value( - member, PermissionSets.EDIT_APPLICATION_TEAM - ), - "perms_env_mgmt": get_form_permission_value( - member, PermissionSets.EDIT_APPLICATION_ENVIRONMENTS - ), - "perms_del_env": get_form_permission_value( - member, PermissionSets.DELETE_APPLICATION_ENVIRONMENTS - ), - } - roles = EnvironmentRoles.get_for_application_member(member.id) - environment_roles = [ - { - "environment_id": str(role.environment.id), - "environment_name": role.environment.name, - "role": role.role, - } - for role in roles - ] - team_data.append( - { - "role_id": member.id, - "user_name": member.user_name, - "permission_sets": permission_sets, - "environment_roles": environment_roles, - } - ) - - return TeamForm(data={"members": team_data}) - - -def get_new_member_form(application): - env_roles = [ - {"environment_id": e.id, "environment_name": e.name} - for e in application.environments - ] - - return NewMemberForm(data={"environment_roles": env_roles}) - - -def render_team_page(application): - team_form = get_team_form(application) - new_member_form = get_new_member_form(application) - - return render_template( - "portfolios/applications/team.html", - application=application, - team_form=team_form, - new_member_form=new_member_form, - ) - - -@applications_bp.route("/applications//team") -@user_can(Permissions.VIEW_APPLICATION, message="view portfolio applications") -def team(application_id): - application = Applications.get(resource_id=application_id) - return render_team_page(application) - - -@applications_bp.route("/application//team", methods=["POST"]) -@user_can(Permissions.EDIT_APPLICATION_MEMBER, message="update application member") -def update_team(application_id): - application = Applications.get(application_id) - form = TeamForm(http_request.form) - - if form.validate(): - for member_form in form.members: - app_role = ApplicationRoles.get_by_id(member_form.role_id.data) - new_perms = [ - perm - for perm in member_form.data["permission_sets"] - if perm != PermissionSets.VIEW_APPLICATION - ] - ApplicationRoles.update_permission_sets(app_role, new_perms) - - for environment_role_form in member_form.environment_roles: - environment = Environments.get( - environment_role_form.environment_id.data - ) - Environments.update_env_role( - environment, app_role, environment_role_form.data.get("role") - ) - - flash("updated_application_team_settings", application_name=application.name) - - return redirect( - url_for( - "applications.team", - application_id=application_id, - fragment="application-members", - _anchor="application-members", - ) - ) - else: - return (render_team_page(application), 400) - - -def send_application_invitation(invitee_email, inviter_name, token): - body = render_template( - "emails/application/invitation.txt", owner=inviter_name, token=token - ) - send_mail.delay( - [invitee_email], - translate("email.application_invite", {"inviter_name": inviter_name}), - body, - ) - - -@applications_bp.route("/application//members/new", methods=["POST"]) -@user_can( - Permissions.CREATE_APPLICATION_MEMBER, message="create new application member" -) -def create_member(application_id): - application = Applications.get(application_id) - form = NewMemberForm(http_request.form) - - if form.validate(): - try: - invite = Applications.invite( - application=application, - inviter=g.current_user, - user_data=form.user_data.data, - permission_sets_names=form.permission_sets.data, - environment_roles_data=form.environment_roles.data, - ) - - send_application_invitation( - invitee_email=invite.email, - inviter_name=g.current_user.full_name, - token=invite.token, - ) - - flash("new_application_member", user_name=invite.user_name) - - except AlreadyExistsError: - return render_template( - "error.html", message="There was an error processing your request." - ) - else: - pass - # TODO: flash error message - - return redirect( - url_for( - "applications.team", - application_id=application_id, - fragment="application-members", - _anchor="application-members", - ) - ) - - -@applications_bp.route( - "/applications//members//delete", - methods=["POST"], -) -@user_can(Permissions.DELETE_APPLICATION_MEMBER, message="remove application member") -def remove_member(application_id, application_role_id): - application_role = ApplicationRoles.get_by_id(application_role_id) - Applications.remove_member(application_role) - - flash( - "application_member_removed", - user_name=application_role.user_name, - application_name=g.application.name, - ) - - return redirect( - url_for( - "applications.team", - _anchor="application-members", - application_id=g.application.id, - fragment="application-members", - ) - ) diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 88069f14..240c3c35 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -139,6 +139,9 @@ } table { + margin: 0; + width: 100%; + thead { th:first-child { padding-left: 3 * $gap; @@ -282,6 +285,13 @@ .application-content { .subheading { @include subheading; + position: relative; + + .icon-link__add { + position: absolute; + right: 0; + top: 0; + } } .panel { @@ -312,6 +322,34 @@ input#delete-application { margin-top: $gap * 3; } + + .accordion-table__item-content.form-row { + margin-bottom: 0; + margin-top: 0; + padding-bottom: 0; + } + + li.accordion-table__item__expanded { + height: auto; + } + + .environment-list__item { + position: relative; + height: 7rem; + } + + span.accordion-table__item__toggler.icon-link { + font-size: $small-font-size; + font-weight: $font-normal; + position: absolute; + left: -$gap * 1.25; + bottom: 0; + } + + a.application-list-item__environment__csp_link.icon-link { + font-size: $small-font-size; + font-weight: $font-normal; + } } .activity-log { diff --git a/styles/components/_sticky_cta.scss b/styles/components/_sticky_cta.scss index 2d841840..0656c660 100644 --- a/styles/components/_sticky_cta.scss +++ b/styles/components/_sticky_cta.scss @@ -4,7 +4,6 @@ z-index: 10; @include media($medium-screen) { - margin-left: -$gap * 5; margin-right: -$gap * 5; } @@ -53,4 +52,10 @@ } } } + + &-return-link { + padding-top: 1.6rem; + font-size: $small-font-size; + font-weight: $font-bold; + } } diff --git a/styles/elements/_inputs.scss b/styles/elements/_inputs.scss index 7805a2ef..c56c57cf 100644 --- a/styles/elements/_inputs.scss +++ b/styles/elements/_inputs.scss @@ -58,6 +58,7 @@ .usa-input { margin: ($gap * 4) ($gap * 2) ($gap * 4) 0; + max-width: 75rem; @include media($medium-screen) { margin: ($gap * 4) 0; diff --git a/styles/sections/_application_edit.scss b/styles/sections/_application_edit.scss index e487eee1..65ff8e6c 100644 --- a/styles/sections/_application_edit.scss +++ b/styles/sections/_application_edit.scss @@ -60,11 +60,6 @@ } } -.app-team-settings-link { - font-size: $small-font-size; - font-weight: $font-normal; -} - .environment-roles { padding: 0 ($gap * 3) ($gap * 3); diff --git a/templates/components/sticky_cta.html b/templates/components/sticky_cta.html index 75e49ccf..4ba268c0 100644 --- a/templates/components/sticky_cta.html +++ b/templates/components/sticky_cta.html @@ -1,12 +1,23 @@ -{% macro StickyCTA(text) -%} +{% from 'components/icon.html' import Icon %} + +{% macro StickyCTA(text, return_link_url=None, return_link_text=None) -%}
+ {% if return_link_url and return_link_text %} + + {% endif %}

{{ text }}

-
- {{ caller() }} -
+ {% if caller %} +
+ {{ caller() }} +
+ {% endif %}
{%- endmacro %} diff --git a/templates/fragments/applications/add_new_environment.html b/templates/fragments/applications/add_new_environment.html index f1e72e4f..32fded93 100644 --- a/templates/fragments/applications/add_new_environment.html +++ b/templates/fragments/applications/add_new_environment.html @@ -18,25 +18,17 @@ - - + + {{ Icon('plus') }} + {{ "portfolios.applications.add_environment" | translate }} + - diff --git a/templates/fragments/applications/edit_environment_team_form.html b/templates/fragments/applications/edit_environment_team_form.html deleted file mode 100644 index 3703b86a..00000000 --- a/templates/fragments/applications/edit_environment_team_form.html +++ /dev/null @@ -1,97 +0,0 @@ -{% from "components/icon.html" import Icon %} -{% from "components/save_button.html" import SaveButton %} - - -{% for env_form in members_form.envs %} - {% if env_form.env_id.data == env['id'] %} - -
- {{ members_form.csrf_token }} - {{ env_form.env_id() }} - -
-
-

- {{ 'fragments.edit_environment_team_form.unassigned_title' | translate }} -

-

-
    -
    - {{ 'fragments.edit_environment_team_form.no_members' | translate }} -
    -
  • - - - - {{ Icon('edit', classes="icon--medium") }} - -
    -
    -
    -
      -
    • - - -
    • -
    -
    -
    -
    - -
  • -
-
-
- {{ - SaveButton( - text=("common.save" | translate) - ) - }} -
-
-
- -
- {% endif %} -{% endfor %} diff --git a/templates/fragments/applications/edit_environments.html b/templates/fragments/applications/edit_environments.html deleted file mode 100644 index ee3d5377..00000000 --- a/templates/fragments/applications/edit_environments.html +++ /dev/null @@ -1,128 +0,0 @@ -{% from "components/delete_confirmation.html" import DeleteConfirmation %} -{% from "components/icon.html" import Icon %} -{% from "components/modal.html" import Modal %} -{% from "components/options_input.html" import OptionsInput %} -{% from "components/save_button.html" import SaveButton %} -{% from "components/text_input.html" import TextInput %} -{% from "components/toggle_list.html" import ToggleButton, ToggleSection %} - - -
-
-
-
-
{{ 'portfolios.applications.environments_heading' | translate }}
-
- - {{ Icon('info') }} - {{ "portfolios.admin.settings_info" | translate }} - -
-
- -
-
-
{{ "portfolios.applications.environments.name" | translate }}
-
{{ "portfolios.applications.environments.edit_name" | translate }}
-
{{ "common.delete" | translate }}
-
{{ "common.members" | translate }}
-
- -
    - {% for env in environments_obj %} - {% set delete_environment_modal_id = "delete_modal_environment{}".format(env['id']) %} - {% set edit_form = env['edit_form'] %} - - -
  • -
    -
    - {{ env['name'] }} -
    -
    - - {% set edit_environment_button %} - {{ Icon('edit') }} - {% endset %} - - {{ - ToggleButton( - open_html=edit_environment_button, - close_html=edit_environment_button, - section_name="edit" - ) - }} - -
    -
    - - {{ Icon('trash') }} - -
    - -
    - - {% call ToggleSection(section_name="members", classes="environment-roles") %} - {% include 'fragments/applications/edit_environment_team_form.html' %} - {% endcall %} - - {% call ToggleSection(section_name="edit") %} -
      -
    • -
      - {{ edit_form.csrf_token }} - {{ TextInput(edit_form.name, validation='requiredField') }} - {{ - SaveButton( - text=("common.save" | translate) - ) - }} -
      -
    • -
    - {% endcall %} -
  • -
    - - {% call Modal(name=delete_environment_modal_id) %} -

    - {{ 'fragments.edit_environment_team_form.delete_environment_title' | translate }} -

    - - {{ - Alert( - level="warning", - title=('components.modal.destructive_title' | translate), - message=('components.modal.destructive_message' | translate({"resource": "environment"})), - ) - }} - - {{ - DeleteConfirmation( - modal_id=delete_environment_modal_id, - delete_text=('portfolios.applications.environments.delete.button' | translate), - delete_action= url_for('applications.delete_environment', environment_id=env['id']), - form=edit_form - ) - }} - {% endcall %} - {% endfor %} -
-
-
diff --git a/templates/fragments/applications/edit_team.html b/templates/fragments/applications/edit_team.html deleted file mode 100644 index 0f1bcb59..00000000 --- a/templates/fragments/applications/edit_team.html +++ /dev/null @@ -1,88 +0,0 @@ -{% from "components/options_input.html" import OptionsInput %} -{% from "components/toggle_list.html" import ToggleButton, ToggleSection %} - -{{ team_form.csrf_token }} - -{% for member_form in team_form.members %} - {% set delete_modal_id = "delete-user-{}".format(member_form.id) %} - {% set environment_roles_form = member_form.environment_roles %} - {% set permissions_form = member_form.permission_sets %} - - -
  • -
    -
    -
    - {{ member_form.user_name.data }} -
    -
    -
    {{ OptionsInput(permissions_form.perms_team_mgmt, label=False, watch=True) }}
    -
    {{ OptionsInput(permissions_form.perms_env_mgmt, label=False, watch=True) }}
    -
    {{ OptionsInput(permissions_form.perms_del_env, label=False, watch=True) }}
    - -
    - {% call ToggleSection(section_name="environments") %} -
      - {% for environment_form in environment_roles_form %} -
    • - -
      -
      -
      - {{ environment_form.environment_name.data }} -
      -
      -
      - - - -
      -
      -
      -
      - {{ environment_form.role.label }} - {{ environment_form.role(**{"v-on:change": "radioChange", "class": "member-list____role-select__radio"}) }} - - {{ environment_form.environment_id() }} -
      -
      -
      -
    • - {% endfor %} -
    -
    - {% if user_can(permissions.DELETE_APPLICATION_MEMBER) %} - - {{ "portfolios.applications.remove_member.button" | translate }} - - {% endif %} -
    - {% endcall %} - {{ member_form.role_id() }} -
  • -
    -{% endfor %} diff --git a/templates/fragments/applications/read_only_environments.html b/templates/fragments/applications/read_only_environments.html deleted file mode 100644 index 90181a2d..00000000 --- a/templates/fragments/applications/read_only_environments.html +++ /dev/null @@ -1,76 +0,0 @@ -{% from "components/icon.html" import Icon %} -{% from "components/toggle_list.html" import ToggleButton, ToggleSection %} - -
    -
    -
    -
    -
    {{ 'portfolios.applications.environments_heading' | translate }}
    -
    -
    -
    - -
    -
    - {{ "portfolios.applications.environments.name" | translate }} -
    - -
      - {% for env in environments_obj %} - -
    • -
      - - {{ env['name'] }} - - - - {% set open_members_button %} - {{ "common.members" | translate }} ({{ env['member_count'] }}) {{ Icon('caret_down') }} - {% endset %} - - {% set close_members_button %} - {{ "common.members" | translate }} ({{ env['member_count'] }}) {{ Icon('caret_up') }} - {% endset %} - - {{ - ToggleButton( - open_html=open_members_button, - close_html=close_members_button, - section_name="members" - ) - }} - -
      - - {% call ToggleSection(section_name="members") %} -
        - {% for member in env['members'] %} -
      • -
        {{ member }}
        -
      • - {% endfor %} -
      - {% endcall %} - - {% call ToggleSection(section_name="edit") %} -
        -
      • -
        -
        -
        -
        - Row here -
        -
        -
        -
        -
      • -
      - {% endcall %} -
    • -
      - {% endfor %} -
    -
    -
    diff --git a/templates/fragments/applications/read_only_team.html b/templates/fragments/applications/read_only_team.html deleted file mode 100644 index 66d6947a..00000000 --- a/templates/fragments/applications/read_only_team.html +++ /dev/null @@ -1,47 +0,0 @@ -{% from "components/toggle_list.html" import ToggleButton, ToggleSection %} - -{% for member in team_form.members %} - {% set user_permissions = [member.permission_sets.perms_team_mgmt, member.permission_sets.perms_env_mgmt, member.permission_sets.perms_del_env] %} - - {% macro PermissionField(value) %} -
    {{ value }}
    - {% endmacro %} - - -
  • -
    -
    {{ member.user_name.data }}
    - {% for permission in user_permissions %} - {% set perm = dict(permission.choices).get(permission.data) %} - {{ PermissionField(perm) }} - {% endfor %} - -
    - {% call ToggleSection(section_name="environments") %} -
      - {% for environment in member.environment_roles %} -
    • - {{ environment.environment_name.data }} -
    • - {% endfor %} -
    - {% endcall %} -
  • -
    -{% endfor %} diff --git a/templates/portfolios/applications/base.html b/templates/portfolios/applications/base.html index 2b633e70..040afa62 100644 --- a/templates/portfolios/applications/base.html +++ b/templates/portfolios/applications/base.html @@ -1,11 +1,11 @@ {% extends "portfolios/base.html" %} +{% from "components/sticky_cta.html" import StickyCTA %} + {% block portfolio_header %} -
    -
    - {{ secondary_breadcrumb }} -
    -
    + {% if application %} + {{ StickyCTA(text=application.name, return_link_url=url_for('applications.portfolio_applications', portfolio_id=application.portfolio_id), return_link_text="BACK TO APPLICATIONS") }} + {% endif %} {% endblock %} {% block portfolio_content %} diff --git a/templates/portfolios/applications/index.html b/templates/portfolios/applications/index.html index 778cc40b..873078f5 100644 --- a/templates/portfolios/applications/index.html +++ b/templates/portfolios/applications/index.html @@ -51,12 +51,6 @@ {{ "portfolios.applications.app_settings_text" | translate }}
    - - {{ "portfolios.applications.team_text" | translate }} ({{ application.members | length }}) - -
    {% set has_environments = 0 < (application.environments|length) %} Environments ({{ application.environments|length }}) diff --git a/templates/portfolios/applications/settings.html b/templates/portfolios/applications/settings.html index f715ea8e..1c1957b0 100644 --- a/templates/portfolios/applications/settings.html +++ b/templates/portfolios/applications/settings.html @@ -3,16 +3,19 @@ {% from "components/alert.html" import Alert %} {% from "components/delete_confirmation.html" import DeleteConfirmation %} {% from "components/icon.html" import Icon %} +{% import "fragments/applications/new_member_modal_content.html" as member_steps %} {% from "components/modal.html" import Modal %} +{% from "components/multi_step_modal_form.html" import MultiStepModalForm %} {% from "components/pagination.html" import Pagination %} {% from "components/save_button.html" import SaveButton %} {% from "components/text_input.html" import TextInput %} +{% from "components/toggle_list.html" import ToggleButton, ToggleSection %} {% set secondary_breadcrumb = 'portfolios.applications.existing_application_title' | translate({ "application_name": application.name }) %} {% block application_content %} -
    {{ 'portfolios.applications.settings_heading' | translate }}
    +
    {{ 'portfolios.applications.settings.name_description' | translate }}
    {% if user_can(permissions.EDIT_APPLICATION) %} @@ -20,32 +23,16 @@
    {{ application_form.csrf_token }} -

    - {{ "fragments.edit_application_form.explain" | translate }} -

    {{ TextInput(application_form.name, optional=False) }} {{ TextInput(application_form.description, paragraph=True, optional=False) }}
    -
    - {% if user_can(permissions.DELETE_APPLICATION) %} -
    - -
    - {% endif %} -
    @@ -81,26 +68,221 @@ {% endif %} -
    -
    - {% if g.matchesPath("application-environments") %} - {% include "fragments/flash.html" %} + {% if not application.members %} + {% set user_can_invite = user_can(permissions.CREATE_APPLICATION_MEMBER) %} + + - {% if user_can(permissions.CREATE_ENVIRONMENT) %} - {% include "fragments/applications/add_new_environment.html" %} + {% else %} +
    + {{ 'portfolios.applications.settings.team_members' | translate }} + + {% set new_member_modal_name = "add-app-mem" %} + {% if user_can(permissions.CREATE_APPLICATION_MEMBER) %} + + {{ Icon("plus") }} + {{ "portfolios.applications.add_member" | translate }} + + {% endif %} +
    + +
    +
    + {% if g.matchesPath("application-members") %} + {% include "fragments/flash.html" %} {% endif %} + + + + + + + + + + {% for member in members %} + + + + + + {% endfor %} + +
    MemberProject PermissionsEnvironment Access
    {{ member.user_name }} + {% for perm, value in member.permission_sets.items() %} + {{ ("portfolios.applications.members.{}.{}".format(perm, value)) | translate }}
    + {% endfor %} +
    + {% for env in member.environment_roles %} + {{ env.environment_name }}{% if not env == member.environment_roles[-1]%},{% endif %} + {% endfor %} +
    +
    - {% elif user_can(permissions.VIEW_ENVIRONMENT) %} - {% include "fragments/applications/read_only_environments.html" %} + {% if user_can(permissions.CREATE_APPLICATION_MEMBER) %} + {% import "fragments/applications/new_member_modal_content.html" as member_steps %} + {{ MultiStepModalForm( + name=new_member_modal_name, + form=new_member_form, + form_action=url_for("applications.create_member", application_id=application.id), + steps=[ + member_steps.MemberStepOne(new_member_form), + member_steps.MemberStepTwo(new_member_form, application) + ], + ) }} {% endif %} +
    + {% endif %} + +
    + {{ 'common.resource_names.environments' | translate }} + + {% if user_can(permissions.CREATE_ENVIRONMENT) %} + {% include "fragments/applications/add_new_environment.html" %} + {% endif %} +
    + +
    + {% if g.matchesPath("application-environments") %} + {% include "fragments/flash.html" %} + {% endif %} +
    +
    +
      + {% for env in environments_obj %} + {% set edit_form = env['edit_form'] %} + +
    • +
      +
      +
      + + {{ env['name'] }} + + + {% set edit_environment_button %} + {{ Icon('edit') }} + {% endset %} + + {{ + ToggleButton( + open_html=edit_environment_button, + close_html=edit_environment_button, + section_name="edit" + ) + }} + + + {% set members_button = "portfolios.applications.member_count" | translate({'count': env['member_count']}) %} + {{ + ToggleButton( + open_html=members_button, + close_html=members_button, + section_name="members" + ) + }} + +
      +
      + +
      + + {% call ToggleSection(section_name="members") %} +
        + {% for member in env['members'] %} +
      • + {{ member }} +
      • + {% endfor %} +
      + {% endcall %} + + {% call ToggleSection(section_name="edit") %} +
        +
      • +
        + {{ edit_form.csrf_token }} + {{ TextInput(edit_form.name, validation='requiredField') }} + {{ + SaveButton( + text=("common.save" | translate) + ) + }} +
        +
      • +
      + {% endcall %} +
    • +
      + {% endfor %} +
    +
    +
    + {% if user_can(permissions.DELETE_APPLICATION) %} + {% set env_count = application.environments | length %} + {% if env_count == 1 %} + {% set pluralized_env = "environment" %} + {% else %} + {% set pluralized_env = "environments" %} + {% endif %} + +
    + {{ "portfolios.applications.delete.subheading" | translate }} +
    + +
    +
    +
    +
    + {{ "portfolios.applications.delete.panel_text" | translate({"name": application.name, "env_count": env_count , "pluralized_env": pluralized_env}) | safe }} +
    +
    +
    + +
    +
    +
    +
    +
    + {% call Modal(name="delete-application") %}

    {{ "portfolios.applications.delete.header" | translate }}

    diff --git a/templates/portfolios/applications/team.html b/templates/portfolios/applications/team.html deleted file mode 100644 index ac71a3f8..00000000 --- a/templates/portfolios/applications/team.html +++ /dev/null @@ -1,154 +0,0 @@ -{% extends "portfolios/applications/base.html" %} - -{% from "components/icon.html" import Icon %} -{% from "components/multi_step_modal_form.html" import MultiStepModalForm %} -{% from 'components/save_button.html' import SaveButton %} -{% import "fragments/applications/new_member_modal_content.html" as member_steps %} -{% from "components/alert.html" import Alert %} -{% from "components/delete_confirmation.html" import DeleteConfirmation %} -{% from "components/modal.html" import Modal %} - -{% set secondary_breadcrumb = 'portfolios.applications.team_settings.title' | translate({ "application_name": application.name }) %} - -{% block application_content %} - {% if not application.members %} - {% set user_can_invite = user_can(permissions.CREATE_APPLICATION_MEMBER) %} - -
    -

    {{ ("portfolios.applications.team_settings.blank_slate.title" | translate) }}

    - - {{ Icon('avatar') }} - - {% if not user_can_invite %} -

    {{ ("portfolios.applications.team_settings.blank_slate.sub_message" | translate) }}

    - {% endif %} - - {% if user_can_invite %} - {% set new_member_modal_name = "add-app-mem" %} - - {{ "portfolios.applications.team_settings.blank_slate.action_label" | translate }} - - {{ MultiStepModalForm( - name=new_member_modal_name, - form=new_member_form, - form_action=url_for("applications.create_member", application_id=application.id), - steps=[ - member_steps.MemberStepOne(new_member_form), - member_steps.MemberStepTwo(new_member_form, application) - ], - ) }} - {% endif %} -
    - - {% else %} -
    - {{ 'portfolios.applications.team_settings.subheading' | translate }} -
    - -
    - -
    -
    - {% if g.matchesPath("application-members") %} - {% include "fragments/flash.html" %} - {% endif %} -
    -
    -
    -
    - {{ "portfolios.applications.team_settings.section.title" | translate({ "application_name": application.name }) }} -

    Members ({{ team_form.members | length }})

    -
    -
    -
    -
    - -
    -
    -
    - {{ "common.name" | translate }} -
    -
    - {{ "portfolios.applications.team_settings.section.table.team_management" | translate }} -
    -
    - {{ "portfolios.applications.team_settings.section.table.environment_management" | translate }} -
    -
    - {{ "portfolios.applications.team_settings.section.table.delete_access" | translate }} -
    -
    -   -
    -
    -
      - {% if user_can(permissions.EDIT_APPLICATION_MEMBER) %} - {% include "fragments/applications/edit_team.html" %} - {% elif user_can(permissions.VIEW_APPLICATION_MEMBER) %} - {% include "fragments/applications/read_only_team.html" %} - {% endif %} -
    -
    - - -
    -
    -
    - - {% if user_can(permissions.DELETE_APPLICATION_MEMBER) %} - {% for member_form in team_form.members %} - {% set delete_modal_id = "delete-user-{}".format(member_form.id) %} - {% call Modal(name=delete_modal_id) %} -

    - {{ "portfolios.applications.remove_member.header" | translate }} -

    - - {{ - Alert( - title=("components.modal.destructive_title" | translate), - message=("portfolios.applications.remove_member.alert.message" | translate({"user_name": member_form.user_name.data})), - level="warning" - ) - }} - - {{ - DeleteConfirmation( - modal_id=delete_modal_id, - delete_text=('portfolios.applications.remove_member.button' | translate), - delete_action=url_for('applications.remove_member', application_id=application.id, application_role_id=member_form.data.role_id), - form=member_form - ) - }} - {% endcall %} - {% endfor %} - {% endif %} - - {% if user_can(permissions.CREATE_APPLICATION_MEMBER) %} - {% import "fragments/applications/new_member_modal_content.html" as member_steps %} - {{ MultiStepModalForm( - name=new_member_modal_name, - form=new_member_form, - form_action=url_for("applications.create_member", application_id=application.id), - steps=[ - member_steps.MemberStepOne(new_member_form), - member_steps.MemberStepTwo(new_member_form, application) - ], - ) }} - {% endif %} -
    - {% endif %} -{% endblock %} diff --git a/tests/routes/applications/test_settings.py b/tests/routes/applications/test_settings.py index 1e184048..9222167d 100644 --- a/tests/routes/applications/test_settings.py +++ b/tests/routes/applications/test_settings.py @@ -1,5 +1,7 @@ import pytest +import uuid from flask import url_for, get_flashed_messages +from unittest.mock import Mock from tests.factories import * @@ -424,3 +426,122 @@ def test_delete_environment(client, user_session): assert environment.name in message["message"] # deletes environment assert len(application.environments) == 0 + + +def test_create_member(monkeypatch, client, user_session, session): + job_mock = Mock() + monkeypatch.setattr("atst.jobs.send_mail.delay", job_mock) + user = UserFactory.create() + application = ApplicationFactory.create( + environments=[{"name": "Naboo"}, {"name": "Endor"}] + ) + env = application.environments[0] + env_1 = application.environments[1] + + user_session(application.portfolio.owner) + + response = client.post( + url_for("applications.create_member", application_id=application.id), + data={ + "user_data-first_name": user.first_name, + "user_data-last_name": user.last_name, + "user_data-dod_id": user.dod_id, + "user_data-email": user.email, + "environment_roles-0-environment_id": env.id, + "environment_roles-0-role": "Basic Access", + "environment_roles-0-environment_name": env.name, + "environment_roles-1-environment_id": env_1.id, + "environment_roles-1-role": NO_ACCESS, + "environment_roles-1-environment_name": env_1.name, + "permission_sets-perms_env_mgmt": True, + "permission_sets-perms_team_mgmt": True, + "permission_sets-perms_del_env": True, + }, + ) + + assert response.status_code == 302 + expected_url = url_for( + "applications.settings", + application_id=application.id, + fragment="application-members", + _anchor="application-members", + _external=True, + ) + assert response.location == expected_url + assert len(application.roles) == 1 + environment_roles = application.roles[0].environment_roles + assert len(environment_roles) == 1 + assert environment_roles[0].environment == env + + invitation = ( + session.query(ApplicationInvitation).filter_by(dod_id=user.dod_id).one() + ) + assert invitation.role.application == application + + assert job_mock.called + + +def test_remove_member_success(client, user_session): + user = UserFactory.create() + application = ApplicationFactory.create() + application_role = ApplicationRoleFactory.create(application=application, user=user) + + user_session(application.portfolio.owner) + + response = client.post( + url_for( + "applications.remove_member", + application_id=application.id, + application_role_id=application_role.id, + ) + ) + + assert response.status_code == 302 + assert response.location == url_for( + "applications.settings", + _anchor="application-members", + _external=True, + application_id=application.id, + fragment="application-members", + ) + + +def test_remove_new_member_success(client, user_session): + application = ApplicationFactory.create() + application_role = ApplicationRoleFactory.create(application=application, user=None) + + user_session(application.portfolio.owner) + + response = client.post( + url_for( + "applications.remove_member", + application_id=application.id, + application_role_id=application_role.id, + ) + ) + + assert response.status_code == 302 + assert response.location == url_for( + "applications.settings", + _anchor="application-members", + _external=True, + application_id=application.id, + fragment="application-members", + ) + + +def test_remove_member_failure(client, user_session): + user = UserFactory.create() + application = ApplicationFactory.create() + + user_session(application.portfolio.owner) + + response = client.post( + url_for( + "applications.remove_member", + application_id=application.id, + application_role_id=uuid.uuid4(), + ) + ) + + assert response.status_code == 404 diff --git a/tests/routes/applications/test_team.py b/tests/routes/applications/test_team.py deleted file mode 100644 index 2ba80a9e..00000000 --- a/tests/routes/applications/test_team.py +++ /dev/null @@ -1,269 +0,0 @@ -import uuid -from unittest.mock import Mock - -from flask import url_for - -from atst.domain.permission_sets import PermissionSets -from atst.models import CSPRole -from atst.forms.data import ENV_ROLE_NO_ACCESS as NO_ACCESS - -from tests.factories import * - - -def test_application_team(client, user_session): - portfolio = PortfolioFactory.create() - application = ApplicationFactory.create(portfolio=portfolio) - - user_session(portfolio.owner) - - response = client.get(url_for("applications.team", application_id=application.id)) - assert response.status_code == 200 - - -def test_update_team_permissions(client, user_session): - application = ApplicationFactory.create() - owner = application.portfolio.owner - app_role = ApplicationRoleFactory.create( - application=application, permission_sets=[] - ) - user_session(owner) - response = client.post( - url_for("applications.update_team", application_id=application.id), - data={ - "members-0-role_id": app_role.id, - "members-0-permission_sets-perms_team_mgmt": PermissionSets.EDIT_APPLICATION_TEAM, - "members-0-permission_sets-perms_env_mgmt": PermissionSets.EDIT_APPLICATION_ENVIRONMENTS, - "members-0-permission_sets-perms_del_env": PermissionSets.DELETE_APPLICATION_ENVIRONMENTS, - }, - ) - - assert response.status_code == 302 - actual_perms_names = [perm.name for perm in app_role.permission_sets] - expected_perms_names = [ - PermissionSets.VIEW_APPLICATION, - PermissionSets.EDIT_APPLICATION_TEAM, - PermissionSets.EDIT_APPLICATION_ENVIRONMENTS, - PermissionSets.DELETE_APPLICATION_ENVIRONMENTS, - ] - assert expected_perms_names == actual_perms_names - - -def test_update_team_with_bad_permission_sets(client, user_session): - application = ApplicationFactory.create() - owner = application.portfolio.owner - app_role = ApplicationRoleFactory.create( - application=application, permission_sets=[] - ) - permission_sets = app_role.permission_sets - - user_session(owner) - response = client.post( - url_for("applications.update_team", application_id=application.id), - data={ - "members-0-role_id": app_role.id, - "members-0-permission_sets-perms_team_mgmt": PermissionSets.EDIT_APPLICATION_TEAM, - "members-0-permission_sets-perms_env_mgmt": "some random string", - }, - ) - assert response.status_code == 400 - assert app_role.permission_sets == permission_sets - - -def test_update_team_with_non_app_user(client, user_session): - application = ApplicationFactory.create() - owner = application.portfolio.owner - - user_session(owner) - response = client.post( - url_for("applications.update_team", application_id=application.id), - data={ - "members-0-role_id": str(uuid.uuid4()), - "members-0-permission_sets-perms_team_mgmt": PermissionSets.EDIT_APPLICATION_TEAM, - "members-0-permission_sets-perms_env_mgmt": PermissionSets.EDIT_APPLICATION_ENVIRONMENTS, - "members-0-permission_sets-perms_del_env": PermissionSets.DELETE_APPLICATION_ENVIRONMENTS, - }, - ) - - assert response.status_code == 404 - - -def test_update_team_environment_roles(client, user_session): - application = ApplicationFactory.create() - owner = application.portfolio.owner - app_role = ApplicationRoleFactory.create( - application=application, permission_sets=[] - ) - environment = EnvironmentFactory.create(application=application) - env_role = EnvironmentRoleFactory.create( - application_role=app_role, - environment=environment, - role=CSPRole.NETWORK_ADMIN.value, - ) - user_session(owner) - response = client.post( - url_for("applications.update_team", application_id=application.id), - data={ - "members-0-role_id": app_role.id, - "members-0-permission_sets-perms_team_mgmt": PermissionSets.EDIT_APPLICATION_TEAM, - "members-0-permission_sets-perms_env_mgmt": PermissionSets.EDIT_APPLICATION_ENVIRONMENTS, - "members-0-permission_sets-perms_del_env": PermissionSets.DELETE_APPLICATION_ENVIRONMENTS, - "members-0-environment_roles-0-environment_id": environment.id, - "members-0-environment_roles-0-role": CSPRole.TECHNICAL_READ.value, - }, - ) - - assert response.status_code == 302 - assert env_role.role == CSPRole.TECHNICAL_READ.value - - -def test_update_team_revoke_environment_access(client, user_session, db, session): - application = ApplicationFactory.create() - owner = application.portfolio.owner - user = UserFactory.create() - app_role = ApplicationRoleFactory.create( - application=application, user=user, permission_sets=[] - ) - environment = EnvironmentFactory.create(application=application) - env_role = EnvironmentRoleFactory.create( - application_role=app_role, - environment=environment, - role=CSPRole.BASIC_ACCESS.value, - ) - assert user in environment.users - - user_session(owner) - response = client.post( - url_for("applications.update_team", application_id=application.id), - data={ - "members-0-role_id": app_role.id, - "members-0-permission_sets-perms_team_mgmt": PermissionSets.EDIT_APPLICATION_TEAM, - "members-0-permission_sets-perms_env_mgmt": PermissionSets.EDIT_APPLICATION_ENVIRONMENTS, - "members-0-permission_sets-perms_del_env": PermissionSets.DELETE_APPLICATION_ENVIRONMENTS, - "members-0-environment_roles-0-environment_id": environment.id, - "members-0-environment_roles-0-role": NO_ACCESS, - }, - ) - - assert response.status_code == 302 - env_role_exists = db.exists().where(EnvironmentRole.id == env_role.id) - assert not session.query(env_role_exists).scalar() - assert user not in environment.users - - -def test_create_member(monkeypatch, client, user_session, session): - job_mock = Mock() - monkeypatch.setattr("atst.jobs.send_mail.delay", job_mock) - user = UserFactory.create() - application = ApplicationFactory.create( - environments=[{"name": "Naboo"}, {"name": "Endor"}] - ) - env = application.environments[0] - env_1 = application.environments[1] - - user_session(application.portfolio.owner) - - response = client.post( - url_for("applications.create_member", application_id=application.id), - data={ - "user_data-first_name": user.first_name, - "user_data-last_name": user.last_name, - "user_data-dod_id": user.dod_id, - "user_data-email": user.email, - "environment_roles-0-environment_id": env.id, - "environment_roles-0-role": "Basic Access", - "environment_roles-0-environment_name": env.name, - "environment_roles-1-environment_id": env_1.id, - "environment_roles-1-role": NO_ACCESS, - "environment_roles-1-environment_name": env_1.name, - "permission_sets-perms_env_mgmt": True, - "permission_sets-perms_team_mgmt": True, - "permission_sets-perms_del_env": True, - }, - ) - - assert response.status_code == 302 - expected_url = url_for( - "applications.team", - application_id=application.id, - fragment="application-members", - _anchor="application-members", - _external=True, - ) - assert response.location == expected_url - assert len(application.roles) == 1 - environment_roles = application.roles[0].environment_roles - assert len(environment_roles) == 1 - assert environment_roles[0].environment == env - - invitation = ( - session.query(ApplicationInvitation).filter_by(dod_id=user.dod_id).one() - ) - assert invitation.role.application == application - - assert job_mock.called - - -def test_remove_member_success(client, user_session): - user = UserFactory.create() - application = ApplicationFactory.create() - application_role = ApplicationRoleFactory.create(application=application, user=user) - - user_session(application.portfolio.owner) - - response = client.post( - url_for( - "applications.remove_member", - application_id=application.id, - application_role_id=application_role.id, - ) - ) - - assert response.status_code == 302 - assert response.location == url_for( - "applications.team", - _anchor="application-members", - _external=True, - application_id=application.id, - fragment="application-members", - ) - - -def test_remove_new_member_success(client, user_session): - application = ApplicationFactory.create() - application_role = ApplicationRoleFactory.create(application=application, user=None) - - user_session(application.portfolio.owner) - - response = client.post( - url_for( - "applications.remove_member", - application_id=application.id, - application_role_id=application_role.id, - ) - ) - - assert response.status_code == 302 - assert response.location == url_for( - "applications.team", - _anchor="application-members", - _external=True, - application_id=application.id, - fragment="application-members", - ) - - -def test_remove_member_failure(client, user_session): - user = UserFactory.create() - application = ApplicationFactory.create() - - user_session(application.portfolio.owner) - - response = client.post( - url_for( - "applications.remove_member", - application_id=application.id, - application_role_id=uuid.uuid4(), - ) - ) - - assert response.status_code == 404 diff --git a/tests/test_access.py b/tests/test_access.py index 0d42c300..8a43b97a 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -585,20 +585,6 @@ def test_task_orders_new_post_routes(post_url_assert_status): post_url_assert_status(rando, url, 404, data=data) -def test_applications_application_team_access(get_url_assert_status): - ccpo = UserFactory.create_ccpo() - rando = UserFactory.create() - - portfolio = PortfolioFactory.create() - application = ApplicationFactory.create(portfolio=portfolio) - - url = url_for("applications.team", application_id=application.id) - - get_url_assert_status(ccpo, url, 200) - get_url_assert_status(portfolio.owner, url, 200) - get_url_assert_status(rando, url, 404) - - def test_portfolio_delete_access(post_url_assert_status): rando = UserFactory.create() owner = UserFactory.create() diff --git a/translations.yaml b/translations.yaml index 028bd05e..f2f1a66a 100644 --- a/translations.yaml +++ b/translations.yaml @@ -59,6 +59,7 @@ common: 'no': 'No' response_label: Response required save: Save + save_changes: Save Changes undo: Undo view: View resource_names: @@ -283,13 +284,15 @@ portfolios: portfolio_name: Portfolio name applications: add_application_text: Add a new application - add_environment: Add new environment + add_environment: Create an Environment + add_member: Add a New Team Member add_another_environment: Add another environment app_settings_text: App settings create_button_text: Create create_new_env: Create a new environment. create_new_env_info: Creating an environment gives you access to the Cloud Service Provider. This environment will function within the constraints of the task order, and any costs will be billed against the portfolio. csp_console_text: CSP console + csp_link: Cloud Service Provider Link remove_member: alert: message: '{user_name} will no longer be able to access this application or any of its environments' @@ -300,6 +303,8 @@ portfolios: message: You will lose access to this application and all environments will be removed from the CSP. Your reporting and activity will still be accessible. button: Delete application header: Are you sure you want to delete this application? + panel_text: 'Deleting {name} will delete this application along with all {env_count} {pluralized_env}. This cannot be undone.' + subheading: Delete Application enter_env_name: "Enter environment name:" environments: name: Name @@ -309,8 +314,12 @@ portfolios: environments_description: Each environment created within an application is logically separated from one another for easier management and security. environments_heading: Application environments existing_application_title: '{application_name} Application Settings' + member_count: '{count} members' new_application_title: New Application settings_heading: Application Settings + settings: + name_description: Name and Description + team_members: Team Members team_settings: blank_slate: action_label: Invite a new team member @@ -323,7 +332,6 @@ portfolios: environment_management: Environment Management team_management: Team Management title: '{application_name} Team' - subheading: Team Settings title: '{application_name} Team Settings' add_to_environment: Add to existing environment team_text: Team @@ -335,6 +343,15 @@ portfolios: manage_envs: 'Allow member to add and rename environments within the application.' delete_envs: 'Allow member to delete environments within the application.' manage_team: 'Allow member to add, update, and remove members from the application team.' + perms_team_mgmt: + view_application: View Team + edit_application_team: Edit Team + perms_env_mgmt: + view_application: View Environments + edit_application_environments: Edit Environments + perms_del_env: + view_application: "" + delete_application_environments: Delete Application index: empty: start_button: Start a new JEDI portfolio