Merge pull request #440 from dod-ccpo/resend-invitation

Resend a workspace invitation
This commit is contained in:
richard-dds 2018-11-13 14:46:01 -05:00 committed by GitHub
commit 1b4d054d7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 212 additions and 14 deletions

View File

@ -4,6 +4,8 @@ from sqlalchemy.orm.exc import NoResultFound
from atst.database import db from atst.database import db
from atst.models.invitation import Invitation, Status as InvitationStatus from atst.models.invitation import Invitation, Status as InvitationStatus
from atst.domain.workspace_roles import WorkspaceRoles from atst.domain.workspace_roles import WorkspaceRoles
from atst.domain.authz import Authorization, Permissions
from atst.domain.workspaces import Workspaces
from .exceptions import NotFoundError from .exceptions import NotFoundError
@ -52,11 +54,11 @@ class Invitations(object):
return invite return invite
@classmethod @classmethod
def create(cls, workspace_role, inviter, user): def create(cls, inviter, workspace_role):
invite = Invitation( invite = Invitation(
workspace_role=workspace_role, workspace_role=workspace_role,
inviter=inviter, inviter=inviter,
user=user, user=workspace_role.user,
status=InvitationStatus.PENDING, status=InvitationStatus.PENDING,
expiration_time=Invitations.current_expiration_time(), expiration_time=Invitations.current_expiration_time(),
) )
@ -104,3 +106,18 @@ class Invitations(object):
def revoke(cls, token): def revoke(cls, token):
invite = Invitations._get(token) invite = Invitations._get(token)
return Invitations._update_status(invite, InvitationStatus.REVOKED) return Invitations._update_status(invite, InvitationStatus.REVOKED)
@classmethod
def resend(cls, user, workspace_id, token):
workspace = Workspaces.get(user, workspace_id)
Authorization.check_workspace_permission(
user,
workspace,
Permissions.ASSIGN_AND_UNASSIGN_ATAT_ROLE,
"resend a workspace invitation",
)
previous_invitation = Invitations._get(token)
Invitations._update_status(previous_invitation, InvitationStatus.REVOKED)
return Invitations.create(user, previous_invitation.workspace_role)

View File

@ -81,3 +81,11 @@ class Invitation(Base, TimestampsMixin, AuditableMixin):
def workspace(self): def workspace(self):
if self.workspace_role: if self.workspace_role:
return self.workspace_role.workspace return self.workspace_role.workspace
@property
def user_email(self):
return self.workspace_role.user.email
@property
def user_name(self):
return self.workspace_role.user.full_name

View File

@ -109,6 +109,12 @@ class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
def has_environment_roles(self): def has_environment_roles(self):
return self.num_environment_roles > 0 return self.num_environment_roles > 0
@property
def can_resend_invitation(self):
return self.latest_invitation and (
self.latest_invitation.is_rejected or self.latest_invitation.is_expired
)
Index( Index(
"workspace_role_user_workspace", "workspace_role_user_workspace",

View File

@ -7,10 +7,13 @@ from flask import (
url_for, url_for,
current_app as app, current_app as app,
) )
import pendulum
from . import redirect_after_login_url from . import redirect_after_login_url
from atst.domain.users import Users from atst.domain.users import Users
from atst.queue import queue from atst.queue import queue
from tests.factories import random_service_branch
from atst.utils import pick
bp = Blueprint("dev", __name__) bp = Blueprint("dev", __name__)
@ -21,6 +24,11 @@ _DEV_USERS = {
"last_name": "Stevenson", "last_name": "Stevenson",
"atat_role_name": "ccpo", "atat_role_name": "ccpo",
"email": "sam@example.com", "email": "sam@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
}, },
"amanda": { "amanda": {
"dod_id": "2345678901", "dod_id": "2345678901",
@ -28,6 +36,11 @@ _DEV_USERS = {
"last_name": "Adamson", "last_name": "Adamson",
"atat_role_name": "default", "atat_role_name": "default",
"email": "amanda@example.com", "email": "amanda@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
}, },
"brandon": { "brandon": {
"dod_id": "3456789012", "dod_id": "3456789012",
@ -35,6 +48,11 @@ _DEV_USERS = {
"last_name": "Buchannan", "last_name": "Buchannan",
"atat_role_name": "default", "atat_role_name": "default",
"email": "brandon@example.com", "email": "brandon@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
}, },
"christina": { "christina": {
"dod_id": "4567890123", "dod_id": "4567890123",
@ -42,6 +60,11 @@ _DEV_USERS = {
"last_name": "Collins", "last_name": "Collins",
"atat_role_name": "default", "atat_role_name": "default",
"email": "christina@example.com", "email": "christina@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
}, },
"dominick": { "dominick": {
"dod_id": "5678901234", "dod_id": "5678901234",
@ -49,6 +72,11 @@ _DEV_USERS = {
"last_name": "Domingo", "last_name": "Domingo",
"atat_role_name": "default", "atat_role_name": "default",
"email": "dominick@example.com", "email": "dominick@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
}, },
"erica": { "erica": {
"dod_id": "6789012345", "dod_id": "6789012345",
@ -56,6 +84,11 @@ _DEV_USERS = {
"last_name": "Eichner", "last_name": "Eichner",
"atat_role_name": "default", "atat_role_name": "default",
"email": "erica@example.com", "email": "erica@example.com",
"service_branch": random_service_branch(),
"phone_number": "1234567890",
"citizenship": "United States",
"designation": "Military",
"date_latest_training": pendulum.date(2018, 1, 1),
}, },
} }
@ -66,10 +99,20 @@ def login_dev():
user_data = _DEV_USERS[role] user_data = _DEV_USERS[role]
user = Users.get_or_create_by_dod_id( user = Users.get_or_create_by_dod_id(
user_data["dod_id"], user_data["dod_id"],
atat_role_name=user_data["atat_role_name"], **pick(
first_name=user_data["first_name"], [
last_name=user_data["last_name"], "atat_role_name",
email=user_data["email"], "first_name",
"last_name",
"email",
"service_branch",
"phone_number",
"citizenship",
"designation",
"date_latest_training",
],
user_data,
),
) )
session["user_id"] = user.id session["user_id"] = user.id

View File

@ -103,6 +103,7 @@ def show_workspace(workspace_id):
def workspace_members(workspace_id): def workspace_members(workspace_id):
workspace = Workspaces.get_with_members(g.current_user, workspace_id) workspace = Workspaces.get_with_members(g.current_user, workspace_id)
new_member_name = http_request.args.get("newMemberName") new_member_name = http_request.args.get("newMemberName")
resent_invitation_to = http_request.args.get("resentInvitationTo")
new_member = next( new_member = next(
filter(lambda m: m.user_name == new_member_name, workspace.members), None filter(lambda m: m.user_name == new_member_name, workspace.members), None
) )
@ -127,6 +128,7 @@ def workspace_members(workspace_id):
status_choices=MEMBER_STATUSES, status_choices=MEMBER_STATUSES,
members=members_list, members=members_list,
new_member=new_member, new_member=new_member,
resent_invitation_to=resent_invitation_to,
) )
@ -255,11 +257,12 @@ def send_invite_email(owner_name, token, new_member_email):
def create_member(workspace_id): def create_member(workspace_id):
workspace = Workspaces.get(g.current_user, workspace_id) workspace = Workspaces.get(g.current_user, workspace_id)
form = NewMemberForm(http_request.form) form = NewMemberForm(http_request.form)
user = g.current_user
if form.validate(): if form.validate():
try: try:
new_member = Workspaces.create_member(g.current_user, workspace, form.data) new_member = Workspaces.create_member(user, workspace, form.data)
invite = Invitations.create(new_member, g.current_user, new_member.user) invite = Invitations.create(user, new_member)
send_invite_email( send_invite_email(
g.current_user.full_name, invite.token, new_member.user.email g.current_user.full_name, invite.token, new_member.user.email
) )
@ -373,3 +376,16 @@ def revoke_invitation(workspace_id, token):
Invitations.revoke(token) Invitations.revoke(token)
return redirect(url_for("workspaces.workspace_members", workspace_id=workspace.id)) return redirect(url_for("workspaces.workspace_members", workspace_id=workspace.id))
@bp.route("/workspaces/<workspace_id>/invitations/<token>/resend", methods=["POST"])
def resend_invitation(workspace_id, token):
invite = Invitations.resend(g.current_user, workspace_id, token)
send_invite_email(g.current_user.full_name, invite.token, invite.user_email)
return redirect(
url_for(
"workspaces.workspace_members",
workspace_id=workspace_id,
resentInvitationTo=invite.user_name,
)
)

View File

@ -12,8 +12,9 @@ from atst.domain.requests import Requests
from atst.domain.workspaces import Workspaces from atst.domain.workspaces import Workspaces
from atst.domain.projects import Projects from atst.domain.projects import Projects
from atst.domain.workspace_roles import WorkspaceRoles from atst.domain.workspace_roles import WorkspaceRoles
from atst.models.invitation import Status as InvitationStatus
from atst.domain.exceptions import AlreadyExistsError from atst.domain.exceptions import AlreadyExistsError
from tests.factories import RequestFactory, TaskOrderFactory from tests.factories import RequestFactory, TaskOrderFactory, InvitationFactory
from atst.routes.dev import _DEV_USERS as DEV_USERS from atst.routes.dev import _DEV_USERS as DEV_USERS
WORKSPACE_USERS = [ WORKSPACE_USERS = [
@ -40,6 +41,41 @@ WORKSPACE_USERS = [
}, },
] ]
WORKSPACE_INVITED_USERS = [
{
"first_name": "Frederick",
"last_name": "Fitzgerald",
"email": "frederick@mil.gov",
"workspace_role": "developer",
"dod_id": "0000000004",
"status": InvitationStatus.REJECTED_WRONG_USER
},
{
"first_name": "Gina",
"last_name": "Guzman",
"email": "gina@mil.gov",
"workspace_role": "developer",
"dod_id": "0000000005",
"status": InvitationStatus.REJECTED_EXPIRED
},
{
"first_name": "Hector",
"last_name": "Harper",
"email": "hector@mil.gov",
"workspace_role": "developer",
"dod_id": "0000000006",
"status": InvitationStatus.REVOKED
},
{
"first_name": "Isabella",
"last_name": "Ingram",
"email": "isabella@mil.gov",
"workspace_role": "developer",
"dod_id": "0000000007",
"status": InvitationStatus.PENDING
},
]
def seed_db(): def seed_db():
users = [] users = []
@ -78,6 +114,13 @@ def seed_db():
ws_role = Workspaces.create_member(user, workspace, workspace_role) ws_role = Workspaces.create_member(user, workspace, workspace_role)
WorkspaceRoles.enable(ws_role) WorkspaceRoles.enable(ws_role)
for workspace_role in WORKSPACE_INVITED_USERS:
ws_role = Workspaces.create_member(user, workspace, workspace_role)
invitation = InvitationFactory.build(workspace_role=ws_role, status=workspace_role["status"])
db.session.add(invitation)
db.session.commit()
Projects.create( Projects.create(
user, user,
workspace=workspace, workspace=workspace,

View File

@ -39,6 +39,7 @@
{% if editable %} {% if editable %}
<a href='{{ url_for("users.user") }}' class='icon-link'>edit account details</a> <a href='{{ url_for("users.user") }}' class='icon-link'>edit account details</a>
{% endif %} {% endif %}
<div>
{% if member.latest_invitation.is_pending %} {% if member.latest_invitation.is_pending %}
{{ ConfirmationButton( {{ ConfirmationButton(
"Revoke Invitation", "Revoke Invitation",
@ -46,6 +47,15 @@
form.csrf_token form.csrf_token
) }} ) }}
{% endif %} {% endif %}
{% if member.can_resend_invitation %}
{{ ConfirmationButton (
"Resend Invitation",
url_for("workspaces.resend_invitation", workspace_id=workspace.id, token=member.latest_invitation.token),
form.csrf_token,
confirm_msg="Are you sure? This will send an email to invite the user to join this workspace."
)}}
{% endif %}
</div>
</div> </div>
</div> </div>

View File

@ -33,6 +33,17 @@
) }} ) }}
{% endif %} {% endif %}
{% if resent_invitation_to %}
{% set message -%}
<p>Successfully sent a new invitation to {{ resent_invitation_to }}.</p>
{%- endset %}
{{ Alert('Invitation resent',
message=message,
level='success'
) }}
{% endif %}
{% set member_name = request.args.get("memberName") %} {% set member_name = request.args.get("memberName") %}
{% set updated_role = request.args.get("updatedRole") %} {% set updated_role = request.args.get("updatedRole") %}
{% if updated_role %} {% if updated_role %}

View File

@ -22,7 +22,7 @@ def test_create_invitation():
workspace = WorkspaceFactory.create() workspace = WorkspaceFactory.create()
user = UserFactory.create() user = UserFactory.create()
ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace) ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace)
invite = Invitations.create(ws_role, workspace.owner, user) invite = Invitations.create(workspace.owner, ws_role)
assert invite.user == user assert invite.user == user
assert invite.workspace_role == ws_role assert invite.workspace_role == ws_role
assert invite.inviter == workspace.owner assert invite.inviter == workspace.owner
@ -34,7 +34,7 @@ def test_accept_invitation():
workspace = WorkspaceFactory.create() workspace = WorkspaceFactory.create()
user = UserFactory.create() user = UserFactory.create()
ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace) ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace)
invite = Invitations.create(ws_role, workspace.owner, user) invite = Invitations.create(workspace.owner, ws_role)
assert invite.is_pending assert invite.is_pending
accepted_invite = Invitations.accept(user, invite.token) accepted_invite = Invitations.accept(user, invite.token)
assert accepted_invite.is_accepted assert accepted_invite.is_accepted
@ -89,7 +89,7 @@ def test_accept_invitation_twice():
workspace = WorkspaceFactory.create() workspace = WorkspaceFactory.create()
user = UserFactory.create() user = UserFactory.create()
ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace) ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace)
invite = Invitations.create(ws_role, workspace.owner, user) invite = Invitations.create(workspace.owner, ws_role)
Invitations.accept(user, invite.token) Invitations.accept(user, invite.token)
with pytest.raises(InvitationError): with pytest.raises(InvitationError):
Invitations.accept(user, invite.token) Invitations.accept(user, invite.token)
@ -99,7 +99,17 @@ def test_revoke_invitation():
workspace = WorkspaceFactory.create() workspace = WorkspaceFactory.create()
user = UserFactory.create() user = UserFactory.create()
ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace) ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace)
invite = Invitations.create(ws_role, workspace.owner, user) invite = Invitations.create(workspace.owner, ws_role)
assert invite.is_pending assert invite.is_pending
Invitations.revoke(invite.token) Invitations.revoke(invite.token)
assert invite.is_revoked assert invite.is_revoked
def test_resend_invitation():
workspace = WorkspaceFactory.create()
user = UserFactory.create()
ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace)
invite = Invitations.create(workspace.owner, ws_role)
Invitations.resend(workspace.owner, workspace.id, invite.token)
assert ws_role.invitations[0].is_revoked
assert ws_role.invitations[1].is_pending

View File

@ -21,7 +21,6 @@ from atst.domain.roles import Roles
from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus
from atst.models.environment_role import EnvironmentRole from atst.models.environment_role import EnvironmentRole
from atst.models.invitation import Invitation, Status as InvitationStatus from atst.models.invitation import Invitation, Status as InvitationStatus
from atst.domain.workspaces import Workspaces
from atst.domain.invitations import Invitations from atst.domain.invitations import Invitations

View File

@ -96,3 +96,17 @@ def test_status_when_invitation_is_expired():
] ]
) )
assert workspace_role.display_status == "Invite expired" assert workspace_role.display_status == "Invite expired"
def test_can_not_resend_invitation_if_active():
workspace_role = WorkspaceRoleFactory.create(
invitations=[InvitationFactory.create(status=InvitationStatus.ACCEPTED)]
)
assert not workspace_role.can_resend_invitation
def test_can_resend_invitation_if_expired():
workspace_role = WorkspaceRoleFactory.create(
invitations=[InvitationFactory.create(status=InvitationStatus.REJECTED_EXPIRED)]
)
assert workspace_role.can_resend_invitation

View File

@ -457,3 +457,24 @@ def test_revoke_invitation(client, user_session):
assert response.status_code == 302 assert response.status_code == 302
assert invite.is_revoked assert invite.is_revoked
def test_resend_invitation_sends_email(client, user_session, queue):
user = UserFactory.create()
workspace = WorkspaceFactory.create()
ws_role = WorkspaceRoleFactory.create(
user=user, workspace=workspace, status=WorkspaceRoleStatus.PENDING
)
invite = InvitationFactory.create(
user_id=user.id, workspace_role_id=ws_role.id, status=InvitationStatus.PENDING
)
user_session(workspace.owner)
client.post(
url_for(
"workspaces.resend_invitation",
workspace_id=workspace.id,
token=invite.token,
)
)
assert len(queue.get_queue()) == 1