New backend flow for application invitations.

Like portfolio invitations, now a user is not associated with an
application role until they accept the associated invitation.
- domain method for inviting user to application
- change application route for inviting a member
- ApplicationRole model knows user name from invitation
This commit is contained in:
dandds 2019-06-04 10:27:18 -04:00
parent a2d1c470c1
commit fa50c01e48
6 changed files with 141 additions and 22 deletions

View File

@ -1,13 +1,20 @@
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from atst.database import db
from . import BaseDomainClass from . import BaseDomainClass
from atst.database import db
from atst.domain.application_roles import ApplicationRoles from atst.domain.application_roles import ApplicationRoles
from atst.domain.environment_roles import EnvironmentRoles from atst.domain.environment_roles import EnvironmentRoles
from atst.domain.environments import Environments from atst.domain.environments import Environments
from atst.domain.exceptions import NotFoundError from atst.domain.exceptions import NotFoundError
from atst.domain.invitations import ApplicationInvitations
from atst.domain.users import Users from atst.domain.users import Users
from atst.models import Application, ApplicationRole, ApplicationRoleStatus from atst.models import (
Application,
ApplicationRole,
ApplicationRoleStatus,
EnvironmentRole,
)
from atst.utils import first_or_none
class Applications(BaseDomainClass): class Applications(BaseDomainClass):
@ -104,6 +111,52 @@ class Applications(BaseDomainClass):
return application_role return application_role
@classmethod
def invite(
cls,
application,
inviter,
user_data,
permission_sets_names=None,
environment_roles_data=None,
):
permission_sets_names = permission_sets_names or []
permission_sets = ApplicationRoles._permission_sets_for_names(
permission_sets_names
)
app_role = ApplicationRole(
application=application, permission_sets=permission_sets
)
db.session.add(app_role)
for env_role_data in environment_roles_data:
env_role_name = env_role_data.get("role")
environment_id = env_role_data.get("environment_id")
if env_role_name is not None:
# pylint: disable=cell-var-from-loop
environment = first_or_none(
lambda e: str(e.id) == str(environment_id), application.environments
)
if environment is None:
raise NotFoundError("environment")
else:
env_role = EnvironmentRole(
application_role=app_role,
environment=environment,
role=env_role_name,
)
db.session.add(env_role)
invitation = ApplicationInvitations.create(
inviter=inviter, role=app_role, member_data=user_data
)
db.session.add(invitation)
db.session.commit()
return invitation
@classmethod @classmethod
def remove_member(cls, application, user_id): def remove_member(cls, application, user_id):
application_role = ApplicationRoles.get( application_role = ApplicationRoles.get(

View File

@ -59,12 +59,17 @@ class ApplicationRole(
), ),
) )
@property
def latest_invitation(self):
if self.invitations:
return self.invitations[-1]
@property @property
def user_name(self): def user_name(self):
if self.user: if self.user:
return self.user.full_name return self.user.full_name
else: elif self.latest_invitation:
return None return self.latest_invitation.user_name
def __repr__(self): def __repr__(self):
return "<ApplicationRole(application='{}', user_id='{}', id='{}', permissions={})>".format( return "<ApplicationRole(application='{}', user_id='{}', id='{}', permissions={})>".format(

View File

@ -13,8 +13,8 @@ from atst.domain.users import Users
from atst.forms.application_member import NewForm as NewMemberForm from atst.forms.application_member import NewForm as NewMemberForm
from atst.forms.team import TeamForm from atst.forms.team import TeamForm
from atst.models import Permissions from atst.models import Permissions
from atst.services.invitation import Invitation as InvitationService
from atst.utils.flash import formatted_flash as flash from atst.utils.flash import formatted_flash as flash
from atst.queue import queue
def get_form_permission_value(member, edit_perm_set): def get_form_permission_value(member, edit_perm_set):
@ -125,6 +125,17 @@ def update_team(application_id):
return (render_team_page(application), 400) return (render_team_page(application), 400)
def send_application_invitation(invitee_email, inviter_name, token):
body = render_template(
"emails/application/invitation.txt", owner=inviter_name, token=token
)
queue.send_mail(
[invitee_email],
"{} has invited you to a JEDI cloud application".format(inviter_name),
body,
)
@applications_bp.route("/application/<application_id>/members/new", methods=["POST"]) @applications_bp.route("/application/<application_id>/members/new", methods=["POST"])
@user_can( @user_can(
Permissions.CREATE_APPLICATION_MEMBER, message="create new application member" Permissions.CREATE_APPLICATION_MEMBER, message="create new application member"
@ -135,19 +146,19 @@ def create_member(application_id):
if form.validate(): if form.validate():
try: try:
member = Applications.create_member( invite = Applications.invite(
application, application=application,
form.user_data.data, inviter=g.current_user,
permission_sets=form.permission_sets.data, user_data=form.user_data.data,
permission_sets_names=form.permission_sets.data,
environment_roles_data=form.environment_roles.data, environment_roles_data=form.environment_roles.data,
) )
invite_service = InvitationService( send_application_invitation(
g.current_user, member, form.user_data.data.get("email") invite.email, g.current_user.full_name, invite.token
) )
invite_service.invite()
flash("new_portfolio_member", new_member=member) flash("new_application_member", user_name=invite.user_name)
except AlreadyExistsError: except AlreadyExistsError:
return render_template( return render_template(

View File

@ -67,7 +67,7 @@ MESSAGES = {
"new_application_member": { "new_application_member": {
"title_template": translate("flash.success"), "title_template": translate("flash.success"),
"message_template": """ "message_template": """
<p>{{ "flash.new_application_member" | translate({ "user_name": new_member.user_name }) }}</p> <p>{{ "flash.new_application_member" | translate({ "user_name": user_name }) }}</p>
""", """,
"category": "success", "category": "success",
}, },

View File

@ -189,3 +189,48 @@ def test_remove_member():
) )
is None is None
) )
def test_invite():
application = ApplicationFactory.create()
env1 = EnvironmentFactory.create(application=application)
env2 = EnvironmentFactory.create(application=application)
user_data = UserFactory.dictionary()
permission_sets_names = [PermissionSets.EDIT_APPLICATION_TEAM]
invitation = Applications.invite(
application=application,
inviter=application.portfolio.owner,
user_data=user_data,
permission_sets_names=permission_sets_names,
environment_roles_data=[
{"environment_id": env1.id, "role": CSPRole.BASIC_ACCESS.value},
{"environment_id": env2.id, "role": None},
],
)
member_role = invitation.role
assert invitation.dod_id == user_data["dod_id"]
# view application AND edit application team
assert len(member_role.permission_sets) == 2
env_roles = member_role.environment_roles
assert len(env_roles) == 1
assert env_roles[0].environment == env1
def test_invite_to_nonexistent_environment():
application = ApplicationFactory.create()
env1 = EnvironmentFactory.create(application=application)
user_data = UserFactory.dictionary()
with pytest.raises(NotFoundError):
Applications.invite(
application=application,
inviter=application.portfolio.owner,
user_data=user_data,
environment_roles_data=[
{"environment_id": env1.id, "role": CSPRole.BASIC_ACCESS.value},
{"environment_id": uuid4(), "role": CSPRole.BASIC_ACCESS.value},
],
)

View File

@ -1,10 +1,10 @@
import pytest
import uuid import uuid
from flask import url_for from flask import url_for
from atst.domain.permission_sets import PermissionSets from atst.domain.permission_sets import PermissionSets
from atst.models import CSPRole from atst.models import CSPRole
from atst.forms.data import ENV_ROLE_NO_ACCESS as NO_ACCESS from atst.forms.data import ENV_ROLE_NO_ACCESS as NO_ACCESS
from atst.queue import queue
from tests.factories import * from tests.factories import *
@ -145,7 +145,8 @@ def test_update_team_revoke_environment_access(client, user_session, db, session
assert not session.query(env_role_exists).scalar() assert not session.query(env_role_exists).scalar()
def test_create_member(client, user_session): def test_create_member(client, user_session, session):
queue_length = len(queue.get_queue())
user = UserFactory.create() user = UserFactory.create()
application = ApplicationFactory.create( application = ApplicationFactory.create(
environments=[{"name": "Naboo"}, {"name": "Endor"}] environments=[{"name": "Naboo"}, {"name": "Endor"}]
@ -179,14 +180,18 @@ def test_create_member(client, user_session):
_external=True, _external=True,
) )
assert response.location == expected_url assert response.location == expected_url
assert len(user.application_roles) == 1 assert len(application.roles) == 1
assert user.application_roles[0].application == application environment_roles = application.roles[0].environment_roles
environment_roles = [
er for ar in user.application_roles for er in ar.environment_roles
]
assert len(environment_roles) == 1 assert len(environment_roles) == 1
assert environment_roles[0].environment == env assert environment_roles[0].environment == env
invitation = (
session.query(ApplicationInvitation).filter_by(dod_id=user.dod_id).one()
)
assert invitation.role.application == application
assert len(queue.get_queue()) == queue_length + 1
def test_remove_member_success(client, user_session): def test_remove_member_success(client, user_session):
user = UserFactory.create() user = UserFactory.create()