From 090e13f0837df938a65f0c818edc69fa45f87e21 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Tue, 26 Mar 2019 11:36:58 -0400 Subject: [PATCH] Update Point of Contact --- atst/domain/portfolio_roles.py | 22 ++++ atst/forms/portfolio_member.py | 8 ++ atst/routes/portfolios/index.py | 37 +++++- atst/utils/flash.py | 5 + .../components/multi_step_modal_form.html | 9 +- .../admin/add_new_portfolio_member.html | 5 +- templates/fragments/admin/change_ppoc.html | 82 +++++++++++++ .../fragments/primary_point_of_contact.html | 14 +-- tests/domain/test_portfolio_roles.py | 34 +++++- .../portfolios/test_portfolios_index.py | 113 +++++++++++++++++- translations.yaml | 12 ++ 11 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 templates/fragments/admin/change_ppoc.html diff --git a/atst/domain/portfolio_roles.py b/atst/domain/portfolio_roles.py index cb123b4a..eee9fe3f 100644 --- a/atst/domain/portfolio_roles.py +++ b/atst/domain/portfolio_roles.py @@ -121,6 +121,28 @@ class PortfolioRoles(object): ) return PermissionSets.get_many(perms_set_names) + @classmethod + def make_ppoc(cls, portfolio_role): + portfolio = portfolio_role.portfolio + original_owner_role = PortfolioRoles.get( + portfolio_id=portfolio.id, user_id=portfolio.owner.id + ) + PortfolioRoles.revoke_ppoc_permissions(portfolio_role=original_owner_role) + PortfolioRoles.add( + user=portfolio_role.user, + portfolio_id=portfolio.id, + permission_sets=PortfolioRoles.PORTFOLIO_PERMISSION_SETS, + ) + + @classmethod + def revoke_ppoc_permissions(cls, portfolio_role): + permission_sets = [ + permission_set.name + for permission_set in portfolio_role.permission_sets + if permission_set.name != PermissionSets.PORTFOLIO_POC + ] + PortfolioRoles.update(portfolio_role=portfolio_role, set_names=permission_sets) + @classmethod def disable(cls, portfolio_role): portfolio_role.status = PortfolioRoleStatus.DISABLED diff --git a/atst/forms/portfolio_member.py b/atst/forms/portfolio_member.py index 10009759..e6060756 100644 --- a/atst/forms/portfolio_member.py +++ b/atst/forms/portfolio_member.py @@ -80,3 +80,11 @@ class NewForm(PermissionsForm): translate("forms.new_member.dod_id_label"), validators=[Required(), Length(min=10), IsNumber()], ) + + +class AssignPPOCForm(PermissionsForm): + user_id = SelectField( + label=translate("forms.assign_ppoc.dod_id"), + validators=[Required()], + choices=[("", "- Select -")], + ) diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py index daf768ae..46fa0a0e 100644 --- a/atst/routes/portfolios/index.py +++ b/atst/routes/portfolios/index.py @@ -6,12 +6,14 @@ from . import portfolios_bp from atst.domain.reports import Reports from atst.domain.portfolios import Portfolios from atst.domain.portfolio_roles import PortfolioRoles +from atst.domain.permission_sets import PermissionSets +from atst.domain.users import Users from atst.domain.audit_log import AuditLog from atst.domain.common import Paginator +from atst.domain.exceptions import NotFoundError from atst.forms.portfolio import PortfolioForm import atst.forms.portfolio_member as member_forms from atst.models.permissions import Permissions -from atst.domain.permission_sets import PermissionSets from atst.domain.authz.decorator import user_can_access_decorator as user_can from atst.utils.flash import formatted_flash as flash from atst.domain.exceptions import UnauthorizedError @@ -70,12 +72,19 @@ def render_admin_page(portfolio, form=None): member_perms_form = member_forms.MembersPermissionsForm( data={"members_permissions": members_data} ) + + assign_ppoc_form = member_forms.AssignPPOCForm() + assign_ppoc_form.user_id.choices += [ + (user.id, user.full_name) for user in portfolio.users + ] + return render_template( "portfolios/admin.html", form=form, portfolio_form=portfolio_form, member_perms_form=member_perms_form, member_form=member_forms.NewForm(), + assign_ppoc_form=assign_ppoc_form, portfolio=portfolio, audit_events=audit_events, user=g.current_user, @@ -117,6 +126,32 @@ def edit_portfolio_members(portfolio_id): return render_admin_page(portfolio) +@portfolios_bp.route("/portfolios//update_ppoc", methods=["POST"]) +@user_can(Permissions.EDIT_PORTFOLIO_POC, message="update portfolio ppoc") +def update_ppoc(portfolio_id): + user_id = http_request.form.get("user_id") + + portfolio = Portfolios.get(g.current_user, portfolio_id) + new_ppoc = Users.get(user_id) + + if new_ppoc not in portfolio.users: + raise NotFoundError("user not in portfolio") + + portfolio_role = PortfolioRoles.get(portfolio_id=portfolio_id, user_id=user_id) + PortfolioRoles.make_ppoc(portfolio_role=portfolio_role) + + flash("primary_point_of_contact_changed", ppoc_name=new_ppoc.full_name) + + return redirect( + url_for( + "portfolios.portfolio_admin", + portfolio_id=portfolio.id, + fragment="primary-point-of-contact", + _anchor="primary-point-of-contact", + ) + ) + + @portfolios_bp.route("/portfolios//edit", methods=["POST"]) @user_can(Permissions.EDIT_PORTFOLIO_NAME, message="edit portfolio") def edit_portfolio(portfolio_id): diff --git a/atst/utils/flash.py b/atst/utils/flash.py index 5cf44edd..2b242987 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -2,6 +2,11 @@ from flask import flash, render_template_string from atst.utils.localization import translate MESSAGES = { + "primary_point_of_contact_changed": { + "title_template": "Primary Point of Contact Changed", + "message_template": "You have successfully added {{ ppoc_name }} as Point of Contact. You are no longer the PoC.", + "category": "success", + }, "invitation_resent": { "title_template": "Invitation resent", "message_template": "The {{ officer_type }} has been resent instructions to join this portfolio.", diff --git a/templates/components/multi_step_modal_form.html b/templates/components/multi_step_modal_form.html index 5b068fdc..31b2a1b2 100644 --- a/templates/components/multi_step_modal_form.html +++ b/templates/components/multi_step_modal_form.html @@ -25,13 +25,16 @@ {% endmacro %} -{% macro MultiStepModalForm(name, form, form_action, steps, button_text="", dismissable=False) -%} +{% macro MultiStepModalForm(name, form, form_action, steps, button_icon="", button_text="", link_classes="icon-link modal-link", dismissable=False) -%} {% set step_count = steps|length %}
- + {{ button_text }} - {{ Icon('plus-circle-solid') }} + + {% if button_icon != "" %} + {{ Icon(button_icon) }} + {% endif %} {% call Modal(name=name, dismissable=dismissable, classes="wide") %}
diff --git a/templates/fragments/admin/add_new_portfolio_member.html b/templates/fragments/admin/add_new_portfolio_member.html index fdbf7b2c..a8c91a7a 100644 --- a/templates/fragments/admin/add_new_portfolio_member.html +++ b/templates/fragments/admin/add_new_portfolio_member.html @@ -80,5 +80,6 @@ member_form, url_for("portfolios.create_member", portfolio_id=portfolio.id), [step_one, step_two], - button_text="portfolios.admin.add_new_member" | translate) - }} + button_text=("portfolios.admin.add_new_member" | translate), + button_icon="plus-circle-solid", + ) }} diff --git a/templates/fragments/admin/change_ppoc.html b/templates/fragments/admin/change_ppoc.html new file mode 100644 index 00000000..d42782ac --- /dev/null +++ b/templates/fragments/admin/change_ppoc.html @@ -0,0 +1,82 @@ +{% from "components/icon.html" import Icon %} +{% from "components/selector.html" import Selector %} +{% from "components/text_input.html" import TextInput %} +{% from "components/multi_step_modal_form.html" import MultiStepModalForm %} +{% from "components/alert.html" import Alert %} +{% from "components/options_input.html" import OptionsInput %} + +{% set step_one %} + + + {{ + Alert( + level="warning", + title=("fragments.ppoc.alert.title" | translate), + message=("fragments.ppoc.alert.message" | translate), + ) + }} + +
+
+ {{ + OptionsInput( + assign_ppoc_form.user_id + ) + }} +
+
+
+
+ +{% endset %} + +{% set step_two %} + +{% endset %} + +
+ {{ + MultiStepModalForm( + 'change-ppoc-form', + assign_ppoc_form, + form_action=url_for("portfolios.update_ppoc", portfolio_id=portfolio.id), + steps=[step_one, step_two], + button_text=("fragments.ppoc.update_btn" | translate), + link_classes="usa-button-primary" + ) + }} +
diff --git a/templates/fragments/primary_point_of_contact.html b/templates/fragments/primary_point_of_contact.html index 692bdbd6..fbdaabe5 100644 --- a/templates/fragments/primary_point_of_contact.html +++ b/templates/fragments/primary_point_of_contact.html @@ -1,5 +1,9 @@ -
+
+ {% if g.matchesPath("primary-point-of-contact") %} + {% include "fragments/flash.html" %} + {% endif %} +

{{ "fragments.ppoc.title" | translate }}

{{ "fragments.ppoc.subtitle" | translate }}

@@ -15,11 +19,7 @@

{% if user_can(permissions.EDIT_PORTFOLIO_POC) %} - + {% include "fragments/admin/change_ppoc.html" %} {% endif %}
-
+ diff --git a/tests/domain/test_portfolio_roles.py b/tests/domain/test_portfolio_roles.py index fedd7faf..312e12ca 100644 --- a/tests/domain/test_portfolio_roles.py +++ b/tests/domain/test_portfolio_roles.py @@ -1,7 +1,8 @@ +from atst.domain.permission_sets import PermissionSets from atst.domain.portfolio_roles import PortfolioRoles from atst.domain.users import Users +from atst.models.permissions import Permissions from atst.models.portfolio_role import Status as PortfolioRoleStatus -from atst.domain.permission_sets import PermissionSets from tests.factories import ( PortfolioFactory, @@ -37,3 +38,34 @@ def test_disable_portfolio_role(): PortfolioRoles.disable(portfolio_role=portfolio_role) assert portfolio_role.status == PortfolioRoleStatus.DISABLED + + +def test_revoke_ppoc_permissions(): + portfolio = PortfolioFactory.create() + portfolio_role = PortfolioRoles.get( + portfolio_id=portfolio.id, user_id=portfolio.owner.id + ) + + assert Permissions.EDIT_PORTFOLIO_POC in portfolio_role.permissions + + PortfolioRoles.revoke_ppoc_permissions(portfolio_role=portfolio_role) + assert Permissions.EDIT_PORTFOLIO_POC not in portfolio_role.permissions + + +def test_make_ppoc(): + portfolio = PortfolioFactory.create() + original_owner = portfolio.owner + new_owner = UserFactory.create() + + new_owner_role = PortfolioRoles.add(user=new_owner, portfolio_id=portfolio.id) + + PortfolioRoles.make_ppoc(portfolio_role=new_owner_role) + + assert portfolio.owner is new_owner + assert Permissions.EDIT_PORTFOLIO_POC in new_owner_role.permissions + assert ( + Permissions.EDIT_PORTFOLIO_POC + not in PortfolioRoles.get( + portfolio_id=portfolio.id, user_id=original_owner.id + ).permissions + ) diff --git a/tests/routes/portfolios/test_portfolios_index.py b/tests/routes/portfolios/test_portfolios_index.py index 88e8115f..d6c60ea2 100644 --- a/tests/routes/portfolios/test_portfolios_index.py +++ b/tests/routes/portfolios/test_portfolios_index.py @@ -1,9 +1,12 @@ -from flask import url_for +import pytest +from flask import url_for from atst.domain.permission_sets import PermissionSets +from atst.domain.portfolios import Portfolios from atst.models.permissions import Permissions from atst.domain.portfolio_roles import PortfolioRoles from atst.models.portfolio_role import Status as PortfolioRoleStatus +from atst.domain.exceptions import UnauthorizedError from tests.factories import ( random_future_date, @@ -28,6 +31,114 @@ def test_update_portfolio_name(client, user_session): assert portfolio.name == "a cool new name" +def updating_ppoc_successfully(client, old_ppoc, new_ppoc, portfolio): + response = client.post( + url_for("portfolios.update_ppoc", portfolio_id=portfolio.id, _external=True), + data={"user_id": new_ppoc.id}, + follow_redirects=False, + ) + + assert response.status_code == 302 + assert response.headers["Location"] == url_for( + "portfolios.portfolio_admin", + portfolio_id=portfolio.id, + fragment="primary-point-of-contact", + _anchor="primary-point-of-contact", + _external=True, + ) + assert portfolio.owner.id == new_ppoc.id + assert ( + Permissions.EDIT_PORTFOLIO_POC + in PortfolioRoles.get( + portfolio_id=portfolio.id, user_id=new_ppoc.id + ).permissions + ) + assert ( + Permissions.EDIT_PORTFOLIO_POC + not in PortfolioRoles.get(portfolio.id, old_ppoc.id).permissions + ) + + +def test_update_ppoc_no_user_id_specified(client, user_session): + portfolio = PortfolioFactory.create() + + user_session(portfolio.owner) + + response = client.post( + url_for("portfolios.update_ppoc", portfolio_id=portfolio.id, _external=True), + follow_redirects=False, + ) + + assert response.status_code == 404 + + +def test_update_ppoc_to_member_not_on_portfolio(client, user_session): + portfolio = PortfolioFactory.create() + original_ppoc = portfolio.owner + non_portfolio_member = UserFactory.create() + + user_session(original_ppoc) + + response = client.post( + url_for("portfolios.update_ppoc", portfolio_id=portfolio.id, _external=True), + data={"user_id": non_portfolio_member.id}, + follow_redirects=False, + ) + + assert response.status_code == 404 + assert portfolio.owner.id == original_ppoc.id + + +def test_update_ppoc_when_ppoc(client, user_session): + portfolio = PortfolioFactory.create() + original_ppoc = portfolio.owner + new_ppoc = UserFactory.create() + Portfolios.add_member( + member=new_ppoc, + portfolio=portfolio, + permission_sets=[PermissionSets.VIEW_PORTFOLIO], + ) + + user_session(original_ppoc) + + updating_ppoc_successfully( + client=client, new_ppoc=new_ppoc, old_ppoc=original_ppoc, portfolio=portfolio + ) + + +def test_update_ppoc_when_cpo(client, user_session): + ccpo = UserFactory.create_ccpo() + portfolio = PortfolioFactory.create() + original_ppoc = portfolio.owner + new_ppoc = UserFactory.create() + Portfolios.add_member( + member=new_ppoc, + portfolio=portfolio, + permission_sets=[PermissionSets.VIEW_PORTFOLIO], + ) + + user_session(ccpo) + + updating_ppoc_successfully( + client=client, new_ppoc=new_ppoc, old_ppoc=original_ppoc, portfolio=portfolio + ) + + +def test_update_ppoc_when_not_ppoc(client, user_session): + portfolio = PortfolioFactory.create() + new_owner = UserFactory.create() + + user_session(new_owner) + + response = client.post( + url_for("portfolios.update_ppoc", portfolio_id=portfolio.id, _external=True), + data={"dod_id": new_owner.dod_id}, + follow_redirects=False, + ) + + assert response.status_code == 404 + + def test_portfolio_index_with_existing_portfolios(client, user_session): portfolio = PortfolioFactory.create() user_session(portfolio.owner) diff --git a/translations.yaml b/translations.yaml index fdfcc5b5..bc42fabc 100644 --- a/translations.yaml +++ b/translations.yaml @@ -28,6 +28,8 @@ flash: delete_member_success: You have successfully deleted {member_name} from the portfolio. common: back: Back + cancel: Cancel + confirm: Confirm edit: Edit manage: manage cancel: Cancel @@ -61,6 +63,8 @@ footer: browser_support: JEDI Cloud supported on these web browsers jedi_help_link_text: Questions? Contact your CCPO representative forms: + assign_ppoc: + dod_id: "Select new primary point of contact:" ccpo_review: comment_description: Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. This message will be shared with the person making the JEDI request.. comment_label: Instructions or comments @@ -319,6 +323,14 @@ fragments: title: Primary point of contact (PPoC) subtitle: The PPoC has the ability to edit all aspects of a portfolio and is the only one who can manage the PPoC role. update_btn: Update + assign_user_button_text: Assign User + update_ppoc_confirmation_title: Confirmation + update_ppoc_title: Update Primary Point of Contact. + confirm_alert: + title: Once you assign a new point of contact, you will no longer be able to request portfolio deactivation or manage the point of contact role. + alert: + title: Warning! + message: Selecting a new primary contact gives that member full access to the portfolio and removes your PoC rights. Please be sure you want to proceed. login: ccpo_logo_alt_text: Cloud Computing Program Office Logo certificate_selection: