Merge pull request #1099 from dod-ccpo/resend-invite

Resend invite
This commit is contained in:
leigh-mil 2019-10-08 16:27:56 -04:00 committed by GitHub
commit f877b0a1d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 260 additions and 108 deletions

View File

@ -3,7 +3,7 @@
"files": "^.secrets.baseline$",
"lines": null
},
"generated_at": "2019-10-02T14:53:58Z",
"generated_at": "2019-10-02T23:24:50Z",
"plugins_used": [
{
"base64_limit": 4.5,
@ -194,7 +194,7 @@
"hashed_secret": "e4f14805dfd1e6af030359090c535e149e6b4207",
"is_secret": false,
"is_verified": false,
"line_number": 638,
"line_number": 651,
"type": "Hex High Entropy String"
}
]

View File

@ -117,22 +117,21 @@ class BaseInvitations(object):
return cls._update_status(invite, InvitationStatus.REVOKED)
@classmethod
def resend(cls, inviter, token):
def resend(cls, inviter, token, user_info=None):
previous_invitation = cls._get(token)
cls._update_status(previous_invitation, InvitationStatus.REVOKED)
return cls.create(
inviter,
previous_invitation.role,
{
if not user_info:
user_info = {
"email": previous_invitation.email,
"dod_id": previous_invitation.dod_id,
"first_name": previous_invitation.first_name,
"last_name": previous_invitation.last_name,
"phone_number": previous_invitation.last_name,
},
commit=True,
)
"phone_number": previous_invitation.phone_number,
"phone_ext": previous_invitation.phone_ext,
}
return cls.create(inviter, previous_invitation.role, user_info, commit=True)
class PortfolioInvitations(BaseInvitations):

View File

@ -12,6 +12,7 @@ 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
from atst.forms.member import NewForm as MemberForm
from atst.domain.authz.decorator import user_can_access_decorator as user_can
from atst.models.permissions import Permissions
from atst.domain.permission_sets import PermissionSets
@ -95,6 +96,13 @@ def get_members_data(application):
form = UpdateMemberForm(
environment_roles=env_roles_form_data, **permission_sets
)
update_invite_form = None
if member.latest_invitation and member.latest_invitation.can_resend:
update_invite_form = MemberForm(obj=member.latest_invitation)
else:
update_invite_form = MemberForm()
members_data.append(
{
"role_id": member.id,
@ -103,6 +111,7 @@ def get_members_data(application):
"environment_roles": environment_roles,
"role_status": member.status.value,
"form": form,
"update_invite_form": update_invite_form,
}
)
@ -419,3 +428,46 @@ def revoke_invite(application_id, application_role_id):
_anchor="application-members",
)
)
@applications_bp.route(
"/applications/<application_id>/members/<application_role_id>/resend_invite",
methods=["POST"],
)
@user_can(Permissions.EDIT_APPLICATION_MEMBER, message="resend application invitation")
def resend_invite(application_id, application_role_id):
app_role = ApplicationRoles.get_by_id(application_role_id)
invite = app_role.latest_invitation
form = MemberForm(http_request.form)
if form.validate():
new_invite = ApplicationInvitations.resend(
g.current_user, invite.token, form.data
)
send_application_invitation(
invitee_email=new_invite.email,
inviter_name=g.current_user.full_name,
token=new_invite.token,
)
flash(
"application_invite_resent",
user_name=new_invite.user_name,
application_name=app_role.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",
)
)

View File

@ -39,6 +39,11 @@ MESSAGES = {
"message_template": "There was an error processing the invitation for {{ user_name }} from {{ application_name }}",
"category": "error",
},
"application_invite_resent": {
"title_template": "Application invitation revoked",
"message_template": "You have successfully resent the invite for {{ user_name }} from {{ application_name }}",
"category": "success",
},
"application_invite_revoked": {
"title_template": "Application invitation revoked",
"message_template": "You have successfully revoked the invite for {{ user_name }} from {{ application_name }}",

View File

@ -0,0 +1,71 @@
{% from "components/checkbox_input.html" import CheckboxInput %}
{% from "components/text_input.html" import TextInput %}
{% from "components/phone_input.html" import PhoneInput %}
{% macro PermsFields(form, new=False, member_role_id=None) %}
<h4>{{ "portfolios.applications.members.form.project_perms" | translate }}</h4>
<div class="application-perms">
{% if new %}
{% set team_mgmt = form.perms_team_mgmt.name %}
{% set env_mgmt = form.perms_env_mgmt.name %}
{% set del_env = form.perms_del_env.name %}
{% else %}
{% set team_mgmt = "perms_team_mgmt-{}".format(member_role_id) %}
{% set env_mgmt = "perms_env_mgmt-{}".format(member_role_id) %}
{% set del_env = "perms_del_env-{}".format(member_role_id) %}
{% endif %}
{{ CheckboxInput(form.perms_team_mgmt, classes="input__inline-fields", key=team_mgmt, id=team_mgmt, optional=True) }}
{{ CheckboxInput(form.perms_env_mgmt, classes="input__inline-fields", key=env_mgmt, id=env_mgmt, optional=True) }}
{{ CheckboxInput(form.perms_del_env, classes="input__inline-fields", key=del_env, id=del_env, optional=True) }}
</div>
<div class="environment_roles environment-roles-new">
<h4>{{ "portfolios.applications.members.form.env_access" | translate }}</h4>
<hr>
{% for environment_data in form.environment_roles %}
<optionsinput inline-template
v-bind:initial-value="'{{ environment_data.role.data | string }}'"
v-bind:name="'{{ environment_data.name | string }}'"
v-bind:optional="true"
v-bind:watch="true">
<div class="usa-input">
<fieldset data-ally-disabled="true" v-on:change="onInput" class="usa-input__choices">
<div class="form-row">
<div class="form-col form-col--two-thirds">
<legend>
<div v-bind:class='["usa-input__title-inline", {"environment-name--gray": value === "None" }]'>
{{ environment_data.environment_name.data }}
</div>
</legend>
</div>
<div class="form-col form-col--third">
{{ environment_data.role(**{"v-model": "value"}) }}
</div>
</div>
</fieldset>
</div>
</optionsinput>
{{ environment_data.environment_id() }}
<hr>
{% endfor %}
</div>
{% endmacro %}
{% macro InfoFields(member_form) %}
<div class='form-row'>
{{ TextInput(member_form.first_name, validation='requiredField', optional=False) }}
</div>
<div class='form-row'>
{{ TextInput(member_form.last_name, validation='requiredField', optional=False) }}
</div>
<div class='form-row'>
{{ TextInput(member_form.email, validation='email', optional=False) }}
</div>
<div class="form-row">
{{ PhoneInput(member_form.phone_number, member_form.phone_ext)}}
</div>
<div class='form-row'>
{{ TextInput(member_form.dod_id, validation='dodId', optional=False) }}
</div>
<a href="#">How do I find the DoD ID?</a>
{% endmacro %}

View File

@ -1,52 +0,0 @@
{% from "components/checkbox_input.html" import CheckboxInput %}
{% macro MemberPermsFields(form, new=False, member_role_id=None) %}
<div class="form-content--app-mem">
<h4>{{ "portfolios.applications.members.form.project_perms" | translate }}</h4>
<div class="application-perms">
{% if new %}
{% set team_mgmt = form.perms_team_mgmt.name %}
{% set env_mgmt = form.perms_env_mgmt.name %}
{% set del_env = form.perms_del_env.name %}
{% else %}
{% set team_mgmt = "perms_team_mgmt-{}".format(member_role_id) %}
{% set env_mgmt = "perms_env_mgmt-{}".format(member_role_id) %}
{% set del_env = "perms_del_env-{}".format(member_role_id) %}
{% endif %}
{{ CheckboxInput(form.perms_team_mgmt, classes="input__inline-fields", key=team_mgmt, id=team_mgmt, optional=True) }}
{{ CheckboxInput(form.perms_env_mgmt, classes="input__inline-fields", key=env_mgmt, id=env_mgmt, optional=True) }}
{{ CheckboxInput(form.perms_del_env, classes="input__inline-fields", key=del_env, id=del_env, optional=True) }}
</div>
<div class="environment_roles environment-roles-new">
<h4>{{ "portfolios.applications.members.form.env_access" | translate }}</h4>
<hr>
{% for environment_data in form.environment_roles %}
<optionsinput inline-template
v-bind:initial-value="'{{ environment_data.role.data | string }}'"
v-bind:name="'{{ environment_data.name | string }}'"
v-bind:optional="true"
v-bind:watch="true">
<div class="usa-input">
<fieldset data-ally-disabled="true" v-on:change="onInput" class="usa-input__choices">
<div class="form-row">
<div class="form-col form-col--two-thirds">
<legend>
<div v-bind:class='["usa-input__title-inline", {"environment-name--gray": value === "None" }]'>
{{ environment_data.environment_name.data }}
</div>
</legend>
</div>
<div class="form-col form-col--third">
{{ environment_data.role(**{"v-model": "value"}) }}
</div>
</div>
</fieldset>
</div>
</optionsinput>
{{ environment_data.environment_id() }}
<hr>
{% endfor %}
</div>
</div>
{% endmacro %}

View File

@ -1,8 +1,5 @@
{% from "components/icon.html" import Icon %}
{% from "components/text_input.html" import TextInput %}
{% from "components/checkbox_input.html" import CheckboxInput %}
{% from "components/phone_input.html" import PhoneInput %}
{% from "applications/fragments/member_perms_form_fields.html" import MemberPermsFields %}
{% import "applications/fragments/member_form_fields.html" as member_fields %}
{% macro MemberFormTemplate(title, next_button, previous=True) %}
<div class="modal__form--header">
@ -36,22 +33,7 @@
{% endset %}
{% call MemberFormTemplate(title="portfolios.applications.members.form.add_member"|translate, next_button=next_button, previous=False) %}
<div class='form-row'>
{{ TextInput(member_form.user_data.first_name, validation='requiredField', optional=False) }}
</div>
<div class='form-row'>
{{ TextInput(member_form.user_data.last_name, validation='requiredField', optional=False) }}
</div>
<div class='form-row'>
{{ TextInput(member_form.user_data.email, validation='email', optional=False) }}
</div>
<div class="form-row">
{{ PhoneInput(member_form.user_data.phone_number, member_form.user_data.phone_ext)}}
</div>
<div class='form-row'>
{{ TextInput(member_form.user_data.dod_id, validation='dodId', optional=False) }}
</div>
<a href="#">How do I find the DoD ID?</a>
{{ member_fields.InfoFields(member_form.user_data) }}
{% endcall %}
{% endmacro %}
{% macro MemberStepTwo(member_form, application) %}
@ -64,6 +46,6 @@
{% endset %}
{% call MemberFormTemplate(title="portfolios.applications.members.form.step_2_title"|translate, next_button=next_button) %}
{{ MemberPermsFields(form=member_form) }}
{{ member_fields.PermsFields(form=member_form) }}
{% endcall %}
{% endmacro %}

View File

@ -4,7 +4,6 @@
{% from "components/delete_confirmation.html" import DeleteConfirmation %}
{% from "components/icon.html" import Icon %}
{% import "applications/fragments/new_member_modal_content.html" as member_steps %}
{% from "applications/fragments/member_perms_form_fields.html" import MemberPermsFields %}
{% from "fragments/members.html" import MemberManagementTemplate %}
{% from "components/modal.html" import Modal %}
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
@ -75,11 +74,14 @@
{% endif %}
{{ MemberManagementTemplate(
application,
members,
application,
members,
new_member_form,
"applications.create_member",
user_can(permissions.CREATE_APPLICATION_MEMBER)) }}
user_can_create_app_member=user_can(permissions.CREATE_APPLICATION_MEMBER),
user_can_edit_app_member=user_can(permissions.EDIT_APPLICATION_MEMBER),
user_can_delete_app_member=user_can(permissions.DELETE_APPLICATION_MEMBER),
) }}
<div class="subheading">
{{ 'common.resource_names.environments' | translate }}

View File

@ -1,20 +1,22 @@
{% from "components/alert.html" import Alert %}
{% from "components/icon.html" import Icon %}
{% import "applications/fragments/new_member_modal_content.html" as member_steps %}
{% from "applications/fragments/member_perms_form_fields.html" import MemberPermsFields %}
{% import "applications/fragments/member_form_fields.html" as member_fields %}
{% from "components/modal.html" import Modal %}
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
{% from "components/save_button.html" import SaveButton %}
{% macro MemberManagementTemplate(
application,
application,
members,
new_member_form,
action,
user_can_create_app_member=False
user_can_create_app_member=False,
user_can_edit_app_member=False,
user_can_delete_app_member=False
) %}
{% if g.matchesPath("application-members") %}
{% include "fragments/flash.html" %}
{% endif %}
@ -52,28 +54,65 @@
{% else %}
{% set new_member_modal_name = "add-app-mem" %}
{% for member in members %}
{% set modal_name = "edit_member-{}".format(loop.index) %}
{% call Modal(modal_name) %}
{%- if user_can_edit_app_member %}
{% set modal_name = "edit_member-{}".format(loop.index) %}
{% call Modal(modal_name) %}
<div class="modal__form--header">
<h1>{{ Icon('avatar') }} {{ member.user_name }}</h1>
<hr>
</div>
<base-form inline-template>
<form id='{{ modal_name }}' method="POST" action="{{ url_for('applications.update_member', application_id=application.id, application_role_id=member.role_id) }}">
{{ member.form.csrf_token }}
{{ member_fields.PermsFields(form=member.form, member_role_id=member.role_id) }}
<div class="action-group">
{{ SaveButton(text='Update', element='input', additional_classes='action-group__action') }}
<a class='action-group__action usa-button usa-button-secondary' v-on:click="closeModal('{{ modal_name }}')">{{ "common.cancel" | translate }}</a>
</div>
</form>
</base-form>
{% endcall %}
{%- if member.role_status == 'pending' %}
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %}
{% call Modal(resend_invite_modal, classes="form-content--app-mem") %}
<div class="modal__form--header">
<h1>{{ Icon('avatar') }} {{ member.user_name }}</h1>
<h1>Verify Member Information</h1>
<hr>
</div>
<base-form inline-template>
<form id='{{ modal_name }}' method="POST" action="{{ url_for('applications.update_member', application_id=application.id, application_role_id=member.role_id) }}">
{{ member.form.csrf_token }}
{{ MemberPermsFields(form=member.form, member_role_id=member.role_id) }}
<form id='{{ resend_invite_modal }}' method="POST" action="{{ url_for('applications.resend_invite', application_id=application.id, application_role_id=member.role_id) }}">
{{ member.update_invite_form.csrf_token }}
{{ member_fields.InfoFields(member.update_invite_form) }}
<div class="action-group">
{{ SaveButton(text='Update', element='input', additional_classes='action-group__action') }}
<a class='action-group__action usa-button usa-button-secondary' v-on:click="closeModal('{{ modal_name }}')">{{ "common.cancel" | translate }}</a>
{{ SaveButton(text='Resend Invite', element='input', additional_classes='action-group__action') }}
<a class='action-group__action usa-button usa-button-secondary' v-on:click="closeModal('{{ resend_invite_modal }}')">{{ "common.cancel" | translate }}</a>
</div>
</form>
</base-form>
{% endcall %}
{% endfor %}
{% endif -%}
{% endif -%}
{% if user_can_delete_app_member and member.role_status == 'pending' -%}
{% set revoke_invite_modal = "revoke_invite_{}".format(member.role_id) %}
{% call Modal(name=revoke_invite_modal, dismissable=True) %}
<div class="task-order__modal-cancel">
<form method="post" action="{{ url_for('applications.revoke_invite', application_id=application.id, application_role_id=member.role_id) }}">
{{ member.form.csrf_token }}
<h1>{{ "invites.revoke.modal_heading" | translate({'user_name': member.user_name}) }}</h1>
<div class="task-order__modal-cancel_buttons">
<button class="usa-button usa-button-primary" type="submit">{{ "invites.revoke.submit" | translate }}</button>
<button type='button' v-on:click='closeModal("{{revoke_invite_modal}}")' class="usa-button usa-button-primary">{{ "invites.revoke.cancel" | translate }}</button>
</div>
</form>
</div>
{% endcall %}
{%- endif %}
{% endfor %}
<section class="member-list application-list" id="application-members">
<div class='responsive-table-wrapper'>
<table class="atat-table">
@ -112,10 +151,14 @@
{% endfor %}
</td>
<td>
{% if member.role_status == 'pending' %}
<a href="#">Resend Invite</a><br>
<a href="#">Revoke Invite</a>
{% endif %}
{% if member.role_status == 'pending' -%}
{% set revoke_invite_modal = "revoke_invite_{}".format(member.role_id) %}
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %}
<a v-on:click='openModal("{{ resend_invite_modal }}")'>Resend Invite</a><br>
{% if user_can_delete_app_member -%}
<a v-on:click='openModal("{{ revoke_invite_modal }}")'>{{ 'invites.revoke.button' | translate }}</a>
{%- endif %}
{%- endif %}
</td>
</tr>
{% endfor %}
@ -129,7 +172,6 @@
</div>
{% if user_can_create_app_member %}
{% import "applications/fragments/new_member_modal_content.html" as member_steps %}
{{ MultiStepModalForm(
name=new_member_modal_name,
form=new_member_form,

View File

@ -2,6 +2,7 @@ import pytest
import uuid
from flask import url_for, get_flashed_messages
from unittest.mock import Mock
import datetime
from tests.factories import *
@ -591,3 +592,35 @@ def test_filter_environment_roles():
environment_data = filter_env_roles_form_data(application_role3, [environment])
assert environment_data[0]["role"] == "No Access"
def test_resend_invite(client, user_session, session):
user = UserFactory.create()
# need to set the time created to yesterday, otherwise the original invite and resent
# invite have the same time_created and then we can't rely on time to order the invites
yesterday = datetime.date.today() - datetime.timedelta(days=1)
invite = ApplicationInvitationFactory.create(
user=user, time_created=yesterday, email="original@example.com"
)
app_role = invite.role
application = app_role.application
user_session(application.portfolio.owner)
response = client.post(
url_for(
"applications.resend_invite",
application_id=application.id,
application_role_id=app_role.id,
),
data={
"first_name": user.first_name,
"last_name": user.last_name,
"dod_id": user.dod_id,
"email": "an_email@example.com",
},
)
session.refresh(app_role)
assert response.status_code == 302
assert invite.is_revoked
assert app_role.status == ApplicationRoleStatus.PENDING
assert app_role.latest_invitation.email == "an_email@example.com"

View File

@ -595,6 +595,24 @@ def test_applications_revoke_invite(post_url_assert_status):
post_url_assert_status(user, url, status)
# applications.resend_invite
def test_applications_resend_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.resend_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(