diff --git a/.secrets.baseline b/.secrets.baseline index 9bb34b58..3acc6dd6 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$", "lines": null }, - "generated_at": "2019-09-25T09:43:11Z", + "generated_at": "2019-09-26T13:53:31Z", "plugins_used": [ { "base64_limit": 4.5, @@ -194,7 +194,7 @@ "hashed_secret": "e4f14805dfd1e6af030359090c535e149e6b4207", "is_secret": false, "is_verified": false, - "line_number": 525, + "line_number": 543, "type": "Hex High Entropy String" } ] diff --git a/atst/domain/application_roles.py b/atst/domain/application_roles.py index 2478d351..826f7f6c 100644 --- a/atst/domain/application_roles.py +++ b/atst/domain/application_roles.py @@ -1,6 +1,7 @@ from sqlalchemy.orm.exc import NoResultFound from atst.database import db +from atst.domain.environment_roles import EnvironmentRoles from atst.models import ApplicationRole, ApplicationRoleStatus from .permission_sets import PermissionSets from .exceptions import NotFoundError @@ -70,3 +71,24 @@ class ApplicationRoles(object): db.session.commit() return application_role + + @classmethod + def _update_status(cls, application_role, new_status): + application_role.status = new_status + db.session.add(application_role) + db.session.commit() + + return application_role + + @classmethod + def disable(cls, application_role): + cls._update_status(application_role, ApplicationRoleStatus.DISABLED) + application_role.deleted = True + + for env in application_role.application.environments: + EnvironmentRoles.delete( + application_role_id=application_role.id, environment_id=env.id + ) + + db.session.add(application_role) + db.session.commit() diff --git a/atst/domain/applications.py b/atst/domain/applications.py index 01cfd2b3..21e8782b 100644 --- a/atst/domain/applications.py +++ b/atst/domain/applications.py @@ -2,7 +2,6 @@ from . import BaseDomainClass from flask import g from atst.database import db from atst.domain.application_roles import ApplicationRoles -from atst.domain.environment_roles import EnvironmentRoles from atst.domain.environments import Environments from atst.domain.exceptions import NotFoundError from atst.domain.invitations import ApplicationInvitations @@ -119,16 +118,3 @@ class Applications(BaseDomainClass): db.session.commit() return invitation - - @classmethod - def remove_member(cls, application_role): - application_role.status = ApplicationRoleStatus.DISABLED - application_role.deleted = True - - for env in application_role.application.environments: - EnvironmentRoles.delete( - application_role_id=application_role.id, environment_id=env.id - ) - - db.session.add(application_role) - db.session.commit() diff --git a/atst/domain/invitations.py b/atst/domain/invitations.py index 6b68db92..16f50c87 100644 --- a/atst/domain/invitations.py +++ b/atst/domain/invitations.py @@ -143,3 +143,10 @@ class PortfolioInvitations(BaseInvitations): class ApplicationInvitations(BaseInvitations): model = ApplicationInvitation role_domain_class = ApplicationRoles + + @classmethod + def _update_status(cls, invite, new_status): + invite = super()._update_status(invite, new_status) + ApplicationRoles.disable(invite.role) + + return invite diff --git a/atst/routes/applications/settings.py b/atst/routes/applications/settings.py index 8bc002bc..74800b21 100644 --- a/atst/routes/applications/settings.py +++ b/atst/routes/applications/settings.py @@ -8,6 +8,7 @@ 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.domain.invitations import ApplicationInvitations from atst.forms.application_member import NewForm as NewMemberForm, UpdateMemberForm from atst.forms.application import NameAndDescriptionForm, EditEnvironmentForm from atst.forms.data import ENV_ROLE_NO_ACCESS as NO_ACCESS @@ -332,7 +333,7 @@ def create_member(application_id): @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) + ApplicationRoles.disable(application_role) flash( "application_member_removed", @@ -379,3 +380,38 @@ def update_member(application_id, application_role_id): _anchor="application-members", ) ) + + +@applications_bp.route( + "/applications//members//revoke_invite", + methods=["POST"], +) +@user_can( + Permissions.DELETE_APPLICATION_MEMBER, message="revoke application invitation" +) +def revoke_invite(application_id, application_role_id): + app_role = ApplicationRoles.get_by_id(application_role_id) + invite = app_role.latest_invitation + + if invite.is_pending: + ApplicationInvitations.revoke(invite.token) + flash( + "application_invite_revoked", + user_name=app_role.user_name, + application_name=g.application.name, + ) + else: + flash( + "application_invite_error", + user_name=app_role.user_name, + application_name=g.application.name, + ) + + return redirect( + url_for( + "applications.settings", + application_id=application_id, + fragment="application-members", + _anchor="application-members", + ) + ) diff --git a/atst/utils/flash.py b/atst/utils/flash.py index 8828adbe..3472f1bd 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -27,6 +27,16 @@ MESSAGES = { "message_template": "Application environments have been updated", "category": "success", }, + "application_invite_error": { + "title_template": "Application invitation error", + "message_template": "There was an error processing the invitation for {{ user_name }} from {{ application_name }}", + "category": "error", + }, + "application_invite_revoked": { + "title_template": "Application invitation revoked", + "message_template": "You have successfully revoked the invite for {{ user_name }} from {{ application_name }}", + "category": "success", + }, "application_member_removed": { "title_template": "Team member removed from application", "message_template": "You have successfully deleted {{ user_name }} from {{ application_name }}", diff --git a/script/seed_sample.py b/script/seed_sample.py index a242d20d..11a45530 100644 --- a/script/seed_sample.py +++ b/script/seed_sample.py @@ -22,6 +22,7 @@ from atst.domain.csp.reports import MockReportingProvider from atst.domain.environments import Environments from atst.domain.environment_roles import EnvironmentRoles from atst.domain.exceptions import AlreadyExistsError, NotFoundError +from atst.domain.invitations import ApplicationInvitations from atst.domain.permission_sets import PermissionSets, APPLICATION_PERMISSION_SETS from atst.domain.portfolio_roles import PortfolioRoles from atst.domain.portfolios import Portfolios @@ -245,6 +246,10 @@ def add_applications_to_portfolio(portfolio): permission_set_names=[PermissionSets.EDIT_APPLICATION_TEAM], ) + ApplicationInvitations.create( + portfolio.owner, app_role, user_data, commit=True + ) + user_environments = random.sample( application.environments, k=random.randint(1, len(application.environments)), diff --git a/styles/sections/_task_order.scss b/styles/sections/_task_order.scss index e4a42843..9e0f26a8 100644 --- a/styles/sections/_task_order.scss +++ b/styles/sections/_task_order.scss @@ -274,6 +274,10 @@ .task-order__modal-cancel_buttons { display: flex; justify-content: center; + + button { + margin-top: 0; + } } .clin-card { diff --git a/templates/applications/settings.html b/templates/applications/settings.html index d1af60e8..68a84965 100644 --- a/templates/applications/settings.html +++ b/templates/applications/settings.html @@ -138,6 +138,22 @@ {% endcall %} + + {% if user_can(permissions.DELETE_APPLICATION_MEMBER) and member.role_status == 'pending' %} + {% set revoke_invite_modal = "revoke_invite_{}".format(member.role_id) %} + {% call Modal(name=revoke_invite_modal, dismissable=True) %} +
+
+ {{ member.form.csrf_token }} +

{{ "invites.revoke.modal_heading" | translate({'user_name': member.user_name}) }}

+
+ + +
+
+
+ {% endcall %} + {% endif %} {% endfor %} @@ -175,9 +191,10 @@ {% endfor %} diff --git a/tests/domain/test_application_roles.py b/tests/domain/test_application_roles.py index 753eb69e..a91de2c1 100644 --- a/tests/domain/test_application_roles.py +++ b/tests/domain/test_application_roles.py @@ -1,6 +1,7 @@ import pytest from atst.domain.application_roles import ApplicationRoles +from atst.domain.environment_roles import EnvironmentRoles from atst.domain.exceptions import NotFoundError from atst.domain.permission_sets import PermissionSets from atst.models import ApplicationRoleStatus @@ -66,3 +67,21 @@ def test_get_by_id(): with pytest.raises(NotFoundError): ApplicationRoles.get_by_id(app_role.id) + + +def test_disable(session): + application = ApplicationFactory.create() + user = UserFactory.create() + member_role = ApplicationRoleFactory.create( + application=application, user=user, status=ApplicationRoleStatus.ACTIVE + ) + environment = EnvironmentFactory.create(application=application) + environment_role = EnvironmentRoleFactory.create( + application_role=member_role, environment=environment + ) + assert member_role.status == ApplicationRoleStatus.ACTIVE + + ApplicationRoles.disable(member_role) + session.refresh(member_role) + assert member_role.status == ApplicationRoleStatus.DISABLED + assert not EnvironmentRoles.get_by_user_and_environment(user.id, environment.id) diff --git a/tests/domain/test_applications.py b/tests/domain/test_applications.py index 4e25ab4e..66f4ee01 100644 --- a/tests/domain/test_applications.py +++ b/tests/domain/test_applications.py @@ -134,37 +134,6 @@ def test_for_user(): assert len(user_applications) == 2 -def test_remove_member(): - application = ApplicationFactory.create() - user = UserFactory.create() - member_role = ApplicationRoleFactory.create(application=application, user=user) - environment = EnvironmentFactory.create(application=application) - environment_role = EnvironmentRoleFactory.create( - application_role=member_role, environment=environment - ) - - assert member_role == ApplicationRoles.get( - user_id=user.id, application_id=application.id - ) - - Applications.remove_member(member_role) - - assert ( - ApplicationRoles.get(user_id=user.id, application_id=application.id).status - == ApplicationRoleStatus.DISABLED - ) - - # - # TODO: Why does above raise NotFoundError and this returns None - # - assert ( - EnvironmentRoles.get( - application_role_id=member_role.id, environment_id=environment.id - ) - is None - ) - - def test_invite(): application = ApplicationFactory.create() env1 = EnvironmentFactory.create(application=application) diff --git a/tests/factories.py b/tests/factories.py index daf686e2..5fe45f55 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -259,6 +259,7 @@ class ApplicationInvitationFactory(Base): email = factory.Faker("email") status = InvitationStatus.PENDING expiration_time = PortfolioInvitations.current_expiration_time() + role = factory.SubFactory(ApplicationRoleFactory) class AttachmentFactory(Base): diff --git a/tests/routes/applications/test_settings.py b/tests/routes/applications/test_settings.py index 3d69404c..811bee36 100644 --- a/tests/routes/applications/test_settings.py +++ b/tests/routes/applications/test_settings.py @@ -14,6 +14,7 @@ from atst.domain.common import Paginator from atst.domain.permission_sets import PermissionSets from atst.domain.portfolios import Portfolios from atst.domain.exceptions import NotFoundError +from atst.models.application_role import Status as ApplicationRoleStatus from atst.models.environment_role import CSPRole from atst.models.permissions import Permissions from atst.models.portfolio_role import Status as PortfolioRoleStatus @@ -540,3 +541,21 @@ def test_update_member(client, user_session): # check that the user has roles in the correct envs assert environment_roles[0].environment in [env, env_2] assert environment_roles[1].environment in [env, env_2] + + +def test_revoke_invite(client, user_session): + invite = ApplicationInvitationFactory.create() + app_role = invite.role + application = app_role.application + + user_session(application.portfolio.owner) + response = client.post( + url_for( + "applications.revoke_invite", + application_id=application.id, + application_role_id=app_role.id, + ) + ) + + assert invite.is_revoked + assert app_role.status == ApplicationRoleStatus.DISABLED diff --git a/tests/test_access.py b/tests/test_access.py index f8ef43a2..e864ff4f 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -572,6 +572,24 @@ def test_applications_update_member(post_url_assert_status): post_url_assert_status(rando, url, 404) +# applications.revoke_invite +def test_applications_revoke_invite(post_url_assert_status): + ccpo = UserFactory.create_ccpo() + rando = UserFactory.create() + application = ApplicationFactory.create() + + for user, status in [(ccpo, 302), (application.portfolio.owner, 302), (rando, 404)]: + app_role = ApplicationRoleFactory.create() + invite = ApplicationInvitationFactory.create(role=app_role) + + url = url_for( + "applications.revoke_invite", + application_id=application.id, + application_role_id=app_role.id, + ) + post_url_assert_status(user, url, status) + + # task_orders.download_task_order_pdf def test_task_orders_download_task_order_pdf_access(get_url_assert_status, monkeypatch): monkeypatch.setattr( diff --git a/translations.yaml b/translations.yaml index a88f1fe4..aff4ad76 100644 --- a/translations.yaml +++ b/translations.yaml @@ -257,6 +257,12 @@ fragments: update_btn: Update update_ppoc_confirmation_title: Confirmation update_ppoc_title: Update primary point of contact +invites: + revoke: + button: Revoke Invite + modal_heading: 'Do you want to revoke the invite for {user_name}?' + submit: Yes, revoke it + cancel: No, do not revoke it login: ccpo_logo_alt_text: Cloud Computing Program Office Logo certificate_selection:
- {% if member.role_status == 'pending' %} + {% if user_can(permissions.DELETE_APPLICATION_MEMBER) and member.role_status == 'pending' %} + {% set revoke_invite_modal = "revoke_invite_{}".format(member.role_id) %} Resend Invite
- Revoke Invite + {{ 'invites.revoke.button' | translate }} {% endif %}