Route for adding new application member

- domain method for creating a new application member
- ApplicationInvitations domain class
- nested form for adding a new user that holds user data, application
  permission sets, and environment roles
- Invitation service can infer invitation type based on role it's given
- new invitation email templates
This commit is contained in:
dandds 2019-04-23 11:24:04 -04:00
parent 054f6b80b9
commit ade77e6b91
17 changed files with 284 additions and 25 deletions

View File

@ -2,8 +2,10 @@ from sqlalchemy.orm.exc import NoResultFound
from atst.database import db from atst.database import db
from . import BaseDomainClass from . import BaseDomainClass
from atst.domain.application_roles import ApplicationRoles
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.users import Users
from atst.models.application import Application from atst.models.application import Application
from atst.models.environment import Environment from atst.models.environment import Environment
from atst.models.environment_role import EnvironmentRole from atst.models.environment_role import EnvironmentRole
@ -72,3 +74,30 @@ class Applications(BaseDomainClass):
db.session.add(application) db.session.add(application)
db.session.commit() db.session.commit()
@classmethod
def create_member(
cls, application, user_data, permission_sets=None, environment_roles_data=None
):
permission_sets = [] if permission_sets is None else permission_sets
environment_roles_data = (
[] if environment_roles_data is None else environment_roles_data
)
user = Users.get_or_create_by_dod_id(
user_data["dod_id"],
first_name=user_data["first_name"],
last_name=user_data["last_name"],
phone_number=user_data.get("phone_number"),
email=user_data["email"],
)
application_role = ApplicationRoles.create(user, application, permission_sets)
for env_role_data in environment_roles_data:
role = env_role_data.get("role")
if role:
environment = Environments.get(env_role_data.get("environment_id"))
Environments.add_member(environment, user, env_role_data.get("role"))
return application_role

View File

@ -2,7 +2,7 @@ import datetime
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from atst.database import db from atst.database import db
from atst.models import InvitationStatus, PortfolioInvitation from atst.models import ApplicationInvitation, InvitationStatus, PortfolioInvitation
from atst.domain.portfolio_roles import PortfolioRoles from atst.domain.portfolio_roles import PortfolioRoles
from .exceptions import NotFoundError from .exceptions import NotFoundError
@ -127,3 +127,7 @@ class BaseInvitations(object):
class PortfolioInvitations(BaseInvitations): class PortfolioInvitations(BaseInvitations):
model = PortfolioInvitation model = PortfolioInvitation
class ApplicationInvitations(BaseInvitations):
model = ApplicationInvitation

View File

@ -0,0 +1,41 @@
from wtforms.fields import FormField, FieldList, HiddenField, BooleanField
from .forms import BaseForm
from .member import NewForm as BaseNewMemberForm
from .data import ENV_ROLES
from atst.forms.fields import SelectField
from atst.domain.permission_sets import PermissionSets
class EnvironmentForm(BaseForm):
environment_id = HiddenField()
environment_name = HiddenField()
role = SelectField(environment_name, choices=ENV_ROLES, default=None)
class PermissionsForm(BaseForm):
perms_env_mgmt = BooleanField(None, default=False)
perms_team_mgmt = BooleanField(None, default=False)
perms_del_env = BooleanField(None, default=False)
@property
def data(self):
_data = super().data
perm_sets = []
if _data["perms_env_mgmt"]:
perm_sets.append(PermissionSets.EDIT_APPLICATION_ENVIRONMENTS)
if _data["perms_team_mgmt"]:
perm_sets.append(PermissionSets.EDIT_APPLICATION_TEAM)
if _data["perms_del_env"]:
perm_sets.append(PermissionSets.DELETE_APPLICATION_ENVIRONMENTS)
return perm_sets
class NewForm(BaseForm):
user_data = FormField(BaseNewMemberForm)
permission_sets = FormField(PermissionsForm)
environment_roles = FieldList(FormField(EnvironmentForm))

View File

@ -1,3 +1,4 @@
from atst.models import CSPRole
from atst.utils.localization import translate, translate_duration from atst.utils.localization import translate, translate_duration
from atst.models.environment_role import CSPRole from atst.models.environment_role import CSPRole

View File

@ -7,7 +7,7 @@ from .permission_set import PermissionSet
from .user import User from .user import User
from .portfolio_role import PortfolioRole, Status as PortfolioRoleStatus from .portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
from .application_role import ApplicationRole, Status as ApplicationRoleStatus from .application_role import ApplicationRole, Status as ApplicationRoleStatus
from .environment_role import EnvironmentRole from .environment_role import EnvironmentRole, CSPRole
from .portfolio import Portfolio from .portfolio import Portfolio
from .application import Application from .application import Application
from .environment import Environment from .environment import Environment

View File

@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import UUID
from atst.models import Base, types, mixins from atst.models import Base, types, mixins
from atst.models.permissions import Permissions from atst.models.permissions import Permissions
from atst.models.portfolio_invitation import PortfolioInvitation from atst.models.portfolio_invitation import PortfolioInvitation
from atst.models.application_invitation import ApplicationInvitation
users_permission_sets = Table( users_permission_sets = Table(
@ -39,6 +40,13 @@ class User(
"PortfolioInvitation", foreign_keys=PortfolioInvitation.inviter_id "PortfolioInvitation", foreign_keys=PortfolioInvitation.inviter_id
) )
application_invitations = relationship(
"ApplicationInvitation", foreign_keys=ApplicationInvitation.user_id
)
sent_application_invitations = relationship(
"ApplicationInvitation", foreign_keys=ApplicationInvitation.inviter_id
)
email = Column(String) email = Column(String)
dod_id = Column(String, unique=True, nullable=False) dod_id = Column(String, unique=True, nullable=False)
first_name = Column(String) first_name = Column(String)

View File

@ -1,12 +1,15 @@
from flask import render_template from flask import render_template, request as http_request, g, url_for, redirect
from . import applications_bp from . import applications_bp
from atst.domain.environments import Environments from atst.domain.environments import Environments
from atst.domain.applications import Applications from atst.domain.applications import Applications
from atst.domain.authz.decorator import user_can_access_decorator as user_can 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 from atst.domain.permission_sets import PermissionSets
from atst.forms.application_member import NewForm as NewMemberForm
from atst.models.permissions import Permissions
from atst.services.invitation import Invitation as InvitationService
from atst.utils.flash import formatted_flash as flash
from atst.utils.localization import translate from atst.utils.localization import translate
@ -47,3 +50,45 @@ def team(application_id):
application=application, application=application,
environment_users=environment_users, environment_users=environment_users,
) )
@applications_bp.route("/application/<application_id>/members/new", methods=["POST"])
@user_can(
Permissions.CREATE_APPLICATION_MEMBER, message="create new application member"
)
def create_member(application_id):
application = Applications.get(application_id)
form = NewMemberForm(http_request.form)
if form.validate():
try:
member = Applications.create_member(
application,
form.user_data.data,
permission_sets=form.permission_sets.data,
environment_roles_data=form.environment_roles.data,
)
invite_service = InvitationService(
g.current_user, member, form.user_data.data.get("email")
)
invite_service.invite()
flash("new_portfolio_member", new_member=member)
except AlreadyExistsError:
return render_template(
"error.html", message="There was an error processing your request."
)
else:
pass
# TODO: flash error message
return redirect(
url_for(
"applications.team",
application_id=application_id,
fragment="application-members",
_anchor="application-members",
)
)

View File

@ -9,7 +9,9 @@ from atst.models.permissions import Permissions
def send_invite_email(owner_name, token, new_member_email): def send_invite_email(owner_name, token, new_member_email):
body = render_template("emails/invitation.txt", owner=owner_name, token=token) body = render_template(
"emails/portfolio/invitation.txt", owner=owner_name, token=token
)
queue.send_mail( queue.send_mail(
[new_member_email], [new_member_email],
"{} has invited you to a JEDI Cloud Portfolio".format(owner_name), "{} has invited you to a JEDI Cloud Portfolio".format(owner_name),

View File

@ -1,25 +1,26 @@
from flask import render_template from flask import render_template
from atst.domain.invitations import PortfolioInvitations from atst.domain.invitations import PortfolioInvitations, ApplicationInvitations
from atst.queue import queue from atst.queue import queue
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.domain.portfolio_roles import PortfolioRoles from atst.domain.portfolio_roles import PortfolioRoles
from atst.models import ApplicationRole, PortfolioRole
OFFICER_INVITATIONS = { OFFICER_INVITATIONS = {
"ko_invite": { "ko_invite": {
"role": "contracting_officer", "role": "contracting_officer",
"subject": "Review a task order", "subject": "Review a task order",
"template": "emails/invitation.txt", "template": "emails/portfolio/invitation.txt",
}, },
"cor_invite": { "cor_invite": {
"role": "contracting_officer_representative", "role": "contracting_officer_representative",
"subject": "Help with a task order", "subject": "Help with a task order",
"template": "emails/invitation.txt", "template": "emails/portfolio/invitation.txt",
}, },
"so_invite": { "so_invite": {
"role": "security_officer", "role": "security_officer",
"subject": "Review security for a task order", "subject": "Review security for a task order",
"template": "emails/invitation.txt", "template": "emails/portfolio/invitation.txt",
}, },
} }
@ -47,20 +48,38 @@ def update_officer_invitations(user, task_order):
class Invitation: class Invitation:
def __init__( def __init__(self, inviter, member, email, subject="", email_template=None):
self,
inviter,
member,
email,
subject="{} has invited you to a JEDI cloud portfolio",
email_template="emails/invitation.txt",
):
self.inviter = inviter self.inviter = inviter
self.member = member self.member = member
self.email = email self.email = email
self.subject = subject self.subject = subject
self.email_template = email_template self.email_template = email_template
if isinstance(member, PortfolioRole):
self.email_template = (
"emails/portfolio/invitation.txt"
if self.email_template is None
else self.email_template
)
self.subject = (
"{} has invited you to a JEDI cloud portfolio"
if self.subject is None
else self.subject
)
self.domain_class = PortfolioInvitations
elif isinstance(member, ApplicationRole):
self.email_template = (
"emails/application/invitation.txt"
if self.email_template is None
else self.email_template
)
self.subject = (
"{} has invited you to a JEDI cloud application"
if self.subject is None
else self.subject
)
self.domain_class = ApplicationInvitations
def invite(self): def invite(self):
invite = self._create_invite() invite = self._create_invite()
self._send_invite_email(invite.token) self._send_invite_email(invite.token)
@ -68,7 +87,7 @@ class Invitation:
return invite return invite
def _create_invite(self): def _create_invite(self):
return PortfolioInvitations.create(self.inviter, self.member, self.email) return self.domain_class.create(self.inviter, self.member, self.email)
def _send_invite_email(self, token): def _send_invite_email(self, token):
body = render_template( body = render_template(

View File

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

View File

@ -0,0 +1,10 @@
{% extends "emails/base.txt" %}
{% block content %}
Join this JEDI Cloud Application
{{ owner }} has invited you to join a JEDI Cloud Application. Login now to view or use your JEDI Cloud resources.
{# url_for("application.accept_invitation", token=token, _external=True) #}
{% endblock %}

View File

@ -1,7 +1,4 @@
Join this JEDI Cloud Portfolio {% block content %}{% endblock %}
{{ owner }} has invited you to join a JEDI Cloud Portfolio. Login now to view or use your JEDI Cloud resources.
{{ url_for("portfolios.accept_invitation", token=token, _external=True) }}
What is JEDI Cloud? What is JEDI Cloud?
JEDI Cloud is a DoD enterprise-wide solution for commercial cloud services. JEDI Cloud is a DoD enterprise-wide solution for commercial cloud services.

View File

@ -0,0 +1,10 @@
{% extends "emails/base.txt" %}
{% block content %}
Join this JEDI Cloud Portfolio
{{ owner }} has invited you to join a JEDI Cloud Portfolio. Login now to view or use your JEDI Cloud resources.
{{ url_for("portfolios.accept_invitation", token=token, _external=True) }}
{% endblock %}

View File

@ -1,7 +1,9 @@
import pytest import pytest
from uuid import uuid4 from uuid import uuid4
from atst.models import CSPRole
from atst.domain.applications import Applications from atst.domain.applications import Applications
from atst.domain.permission_sets import PermissionSets
from atst.domain.exceptions import NotFoundError from atst.domain.exceptions import NotFoundError
from tests.factories import ( from tests.factories import (
@ -100,3 +102,29 @@ def test_delete_application(session):
# changes are flushed # changes are flushed
assert not session.dirty assert not session.dirty
def test_create_member():
application = ApplicationFactory.create()
env1 = EnvironmentFactory.create(application=application)
env2 = EnvironmentFactory.create(application=application)
user_data = UserFactory.dictionary()
permission_set_names = [PermissionSets.EDIT_APPLICATION_TEAM]
member_role = Applications.create_member(
application,
user_data,
permission_set_names,
environment_roles_data=[
{"environment_id": env1.id, "role": CSPRole.BASIC_ACCESS.value},
{"environment_id": env2.id, "role": None},
],
)
assert member_role.user.dod_id == user_data["dod_id"]
# view application AND edit application team
assert len(member_role.permission_sets) == 2
env_roles = member_role.user.environment_roles
assert len(env_roles) == 1
assert env_roles[0].environment == env1

View File

@ -1,6 +1,6 @@
from flask import url_for from flask import url_for
from tests.factories import PortfolioFactory, ApplicationFactory from tests.factories import PortfolioFactory, ApplicationFactory, UserFactory
def test_application_team(client, user_session): def test_application_team(client, user_session):
@ -12,3 +12,43 @@ def test_application_team(client, user_session):
response = client.get(url_for("applications.team", application_id=application.id)) response = client.get(url_for("applications.team", application_id=application.id))
assert response.status_code == 200 assert response.status_code == 200
def test_create_member(client, user_session):
user = UserFactory.create()
application = ApplicationFactory.create(
environments=[{"name": "Naboo"}, {"name": "Endor"}]
)
env = application.environments[0]
user_session(application.portfolio.owner)
response = client.post(
url_for("applications.create_member", application_id=application.id),
data={
"user_data-first_name": user.first_name,
"user_data-last_name": user.last_name,
"user_data-dod_id": user.dod_id,
"user_data-email": user.email,
"environment_roles-0-environment_id": env.id,
"environment_roles-0-environment_name": env.name,
"environment_roles-0-role": "Basic Access",
"permission_sets-perms_env_mgmt": True,
"permission_sets-perms_team_mgmt": True,
"permission_sets-perms_del_env": True,
},
)
assert response.status_code == 302
expected_url = url_for(
"applications.team",
application_id=application.id,
fragment="application-members",
_anchor="application-members",
_external=True,
)
assert response.location == expected_url
assert len(user.application_roles) == 1
assert user.application_roles[0].application == application
assert len(user.environment_roles) == 1
assert user.environment_roles[0].environment == env

View File

@ -1,9 +1,15 @@
from tests.factories import UserFactory, PortfolioFactory, PortfolioRoleFactory from tests.factories import (
ApplicationFactory,
ApplicationRoleFactory,
UserFactory,
PortfolioFactory,
PortfolioRoleFactory,
)
from atst.services.invitation import Invitation from atst.services.invitation import Invitation
def test_invite_member(queue): def test_invite_portfolio_member(queue):
inviter = UserFactory.create() inviter = UserFactory.create()
new_member = UserFactory.create() new_member = UserFactory.create()
portfolio = PortfolioFactory.create(owner=inviter) portfolio = PortfolioFactory.create(owner=inviter)
@ -12,3 +18,14 @@ def test_invite_member(queue):
new_invitation = invite_service.invite() new_invitation = invite_service.invite()
assert new_invitation == new_member.portfolio_invitations[0] assert new_invitation == new_member.portfolio_invitations[0]
assert len(queue.get_queue()) == 1 assert len(queue.get_queue()) == 1
def test_invite_application_member(queue):
inviter = UserFactory.create()
new_member = UserFactory.create()
application = ApplicationFactory.create()
member = ApplicationRoleFactory.create(user=new_member, application=application)
invite_service = Invitation(inviter, member, new_member.email)
new_invitation = invite_service.invite()
assert new_invitation == new_member.application_invitations[0]
assert len(queue.get_queue()) == 1

View File

@ -65,6 +65,7 @@ flash:
next_steps: Review next steps below next_steps: Review next steps below
portfolio_home: Go to my portfolio home page portfolio_home: Go to my portfolio home page
success: Success! success: Success!
new_application_member: 'You have successfully invited {user_name} to the team.'
footer: footer:
about_link_text: Joint Enterprise Defense Infrastructure about_link_text: Joint Enterprise Defense Infrastructure
browser_support: JEDI Cloud supported on these web browsers browser_support: JEDI Cloud supported on these web browsers