diff --git a/atst/domain/invitations.py b/atst/domain/invitations.py index bb653693..738525ab 100644 --- a/atst/domain/invitations.py +++ b/atst/domain/invitations.py @@ -4,6 +4,8 @@ from sqlalchemy.orm.exc import NoResultFound from atst.database import db from atst.models.invitation import Invitation, Status as InvitationStatus from atst.domain.workspace_roles import WorkspaceRoles +from atst.domain.authz import Authorization, Permissions +from atst.domain.workspaces import Workspaces from .exceptions import NotFoundError @@ -52,11 +54,11 @@ class Invitations(object): return invite @classmethod - def create(cls, workspace_role, inviter, user): + def create(cls, inviter, workspace_role): invite = Invitation( workspace_role=workspace_role, inviter=inviter, - user=user, + user=workspace_role.user, status=InvitationStatus.PENDING, expiration_time=Invitations.current_expiration_time(), ) @@ -104,3 +106,18 @@ class Invitations(object): def revoke(cls, token): invite = Invitations._get(token) 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) diff --git a/atst/models/invitation.py b/atst/models/invitation.py index ca3eba9c..3768849c 100644 --- a/atst/models/invitation.py +++ b/atst/models/invitation.py @@ -81,3 +81,11 @@ class Invitation(Base, TimestampsMixin, AuditableMixin): def workspace(self): if self.workspace_role: 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 diff --git a/atst/models/workspace_role.py b/atst/models/workspace_role.py index ad223c5f..c006b87a 100644 --- a/atst/models/workspace_role.py +++ b/atst/models/workspace_role.py @@ -109,6 +109,12 @@ class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin): def has_environment_roles(self): 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( "workspace_role_user_workspace", diff --git a/atst/routes/dev.py b/atst/routes/dev.py index 9aa7f5f2..ee43a38d 100644 --- a/atst/routes/dev.py +++ b/atst/routes/dev.py @@ -7,10 +7,13 @@ from flask import ( url_for, current_app as app, ) +import pendulum from . import redirect_after_login_url from atst.domain.users import Users from atst.queue import queue +from tests.factories import random_service_branch +from atst.utils import pick bp = Blueprint("dev", __name__) @@ -21,6 +24,11 @@ _DEV_USERS = { "last_name": "Stevenson", "atat_role_name": "ccpo", "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": { "dod_id": "2345678901", @@ -28,6 +36,11 @@ _DEV_USERS = { "last_name": "Adamson", "atat_role_name": "default", "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": { "dod_id": "3456789012", @@ -35,6 +48,11 @@ _DEV_USERS = { "last_name": "Buchannan", "atat_role_name": "default", "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": { "dod_id": "4567890123", @@ -42,6 +60,11 @@ _DEV_USERS = { "last_name": "Collins", "atat_role_name": "default", "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": { "dod_id": "5678901234", @@ -49,6 +72,11 @@ _DEV_USERS = { "last_name": "Domingo", "atat_role_name": "default", "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": { "dod_id": "6789012345", @@ -56,6 +84,11 @@ _DEV_USERS = { "last_name": "Eichner", "atat_role_name": "default", "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 = Users.get_or_create_by_dod_id( user_data["dod_id"], - atat_role_name=user_data["atat_role_name"], - first_name=user_data["first_name"], - last_name=user_data["last_name"], - email=user_data["email"], + **pick( + [ + "atat_role_name", + "first_name", + "last_name", + "email", + "service_branch", + "phone_number", + "citizenship", + "designation", + "date_latest_training", + ], + user_data, + ), ) session["user_id"] = user.id diff --git a/atst/routes/workspaces.py b/atst/routes/workspaces.py index bbe47803..c216d5ee 100644 --- a/atst/routes/workspaces.py +++ b/atst/routes/workspaces.py @@ -103,6 +103,7 @@ def show_workspace(workspace_id): def workspace_members(workspace_id): workspace = Workspaces.get_with_members(g.current_user, workspace_id) new_member_name = http_request.args.get("newMemberName") + resent_invitation_to = http_request.args.get("resentInvitationTo") new_member = next( 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, members=members_list, 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): workspace = Workspaces.get(g.current_user, workspace_id) form = NewMemberForm(http_request.form) + user = g.current_user if form.validate(): try: - new_member = Workspaces.create_member(g.current_user, workspace, form.data) - invite = Invitations.create(new_member, g.current_user, new_member.user) + new_member = Workspaces.create_member(user, workspace, form.data) + invite = Invitations.create(user, new_member) send_invite_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) return redirect(url_for("workspaces.workspace_members", workspace_id=workspace.id)) + + +@bp.route("/workspaces//invitations//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, + ) + ) diff --git a/script/seed_sample.py b/script/seed_sample.py index 9a3b1ff5..2c718791 100644 --- a/script/seed_sample.py +++ b/script/seed_sample.py @@ -12,8 +12,9 @@ from atst.domain.requests import Requests from atst.domain.workspaces import Workspaces from atst.domain.projects import Projects from atst.domain.workspace_roles import WorkspaceRoles +from atst.models.invitation import Status as InvitationStatus 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 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(): users = [] @@ -78,6 +114,13 @@ def seed_db(): ws_role = Workspaces.create_member(user, workspace, workspace_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( user, workspace=workspace, diff --git a/templates/workspaces/members/edit.html b/templates/workspaces/members/edit.html index e840ffef..0c1e8f7a 100644 --- a/templates/workspaces/members/edit.html +++ b/templates/workspaces/members/edit.html @@ -39,6 +39,7 @@ {% if editable %} edit account details {% endif %} +
{% if member.latest_invitation.is_pending %} {{ ConfirmationButton( "Revoke Invitation", @@ -46,6 +47,15 @@ form.csrf_token ) }} {% 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 %} +
diff --git a/templates/workspaces/members/index.html b/templates/workspaces/members/index.html index 9b0e1e5f..0c2c17a2 100644 --- a/templates/workspaces/members/index.html +++ b/templates/workspaces/members/index.html @@ -33,6 +33,17 @@ ) }} {% endif %} +{% if resent_invitation_to %} + {% set message -%} +

Successfully sent a new invitation to {{ resent_invitation_to }}.

+ {%- endset %} + + {{ Alert('Invitation resent', + message=message, + level='success' + ) }} +{% endif %} + {% set member_name = request.args.get("memberName") %} {% set updated_role = request.args.get("updatedRole") %} {% if updated_role %} diff --git a/tests/domain/test_invitations.py b/tests/domain/test_invitations.py index 6f5a0093..e63258e8 100644 --- a/tests/domain/test_invitations.py +++ b/tests/domain/test_invitations.py @@ -22,7 +22,7 @@ def test_create_invitation(): workspace = WorkspaceFactory.create() user = UserFactory.create() 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.workspace_role == ws_role assert invite.inviter == workspace.owner @@ -34,7 +34,7 @@ def test_accept_invitation(): workspace = WorkspaceFactory.create() user = UserFactory.create() 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 accepted_invite = Invitations.accept(user, invite.token) assert accepted_invite.is_accepted @@ -89,7 +89,7 @@ def test_accept_invitation_twice(): workspace = WorkspaceFactory.create() user = UserFactory.create() 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) with pytest.raises(InvitationError): Invitations.accept(user, invite.token) @@ -99,7 +99,17 @@ def test_revoke_invitation(): workspace = WorkspaceFactory.create() user = UserFactory.create() 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 Invitations.revoke(invite.token) 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 diff --git a/tests/factories.py b/tests/factories.py index 179fc02d..8d62a22d 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -21,7 +21,6 @@ from atst.domain.roles import Roles from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus from atst.models.environment_role import EnvironmentRole from atst.models.invitation import Invitation, Status as InvitationStatus -from atst.domain.workspaces import Workspaces from atst.domain.invitations import Invitations diff --git a/tests/models/test_workspace_role.py b/tests/models/test_workspace_role.py index 573b67ef..3622e88a 100644 --- a/tests/models/test_workspace_role.py +++ b/tests/models/test_workspace_role.py @@ -96,3 +96,17 @@ def test_status_when_invitation_is_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 diff --git a/tests/routes/test_workspaces.py b/tests/routes/test_workspaces.py index 8d30058f..3737042e 100644 --- a/tests/routes/test_workspaces.py +++ b/tests/routes/test_workspaces.py @@ -457,3 +457,24 @@ def test_revoke_invitation(client, user_session): assert response.status_code == 302 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