diff --git a/atst/domain/applications.py b/atst/domain/applications.py index ba81461d..640242c0 100644 --- a/atst/domain/applications.py +++ b/atst/domain/applications.py @@ -2,8 +2,10 @@ from sqlalchemy.orm.exc import NoResultFound from atst.database import db from . import BaseDomainClass +from atst.domain.application_roles import ApplicationRoles from atst.domain.environments import Environments from atst.domain.exceptions import NotFoundError +from atst.domain.users import Users from atst.models.application import Application from atst.models.environment import Environment from atst.models.environment_role import EnvironmentRole @@ -72,3 +74,30 @@ class Applications(BaseDomainClass): db.session.add(application) 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 diff --git a/atst/domain/invitations.py b/atst/domain/invitations.py index cfedde11..209cd4d1 100644 --- a/atst/domain/invitations.py +++ b/atst/domain/invitations.py @@ -2,7 +2,7 @@ import datetime from sqlalchemy.orm.exc import NoResultFound 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 .exceptions import NotFoundError @@ -127,3 +127,7 @@ class BaseInvitations(object): class PortfolioInvitations(BaseInvitations): model = PortfolioInvitation + + +class ApplicationInvitations(BaseInvitations): + model = ApplicationInvitation diff --git a/atst/forms/application_member.py b/atst/forms/application_member.py new file mode 100644 index 00000000..03c85953 --- /dev/null +++ b/atst/forms/application_member.py @@ -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)) diff --git a/atst/forms/data.py b/atst/forms/data.py index 23ab9c4a..515a70e9 100644 --- a/atst/forms/data.py +++ b/atst/forms/data.py @@ -1,3 +1,4 @@ +from atst.models import CSPRole from atst.utils.localization import translate, translate_duration from atst.models.environment_role import CSPRole diff --git a/atst/models/__init__.py b/atst/models/__init__.py index 7fe45e05..8a3cdbfa 100644 --- a/atst/models/__init__.py +++ b/atst/models/__init__.py @@ -7,7 +7,7 @@ from .permission_set import PermissionSet from .user import User from .portfolio_role import PortfolioRole, Status as PortfolioRoleStatus from .application_role import ApplicationRole, Status as ApplicationRoleStatus -from .environment_role import EnvironmentRole +from .environment_role import EnvironmentRole, CSPRole from .portfolio import Portfolio from .application import Application from .environment import Environment diff --git a/atst/models/user.py b/atst/models/user.py index 0510d867..d40501a4 100644 --- a/atst/models/user.py +++ b/atst/models/user.py @@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import UUID from atst.models import Base, types, mixins from atst.models.permissions import Permissions from atst.models.portfolio_invitation import PortfolioInvitation +from atst.models.application_invitation import ApplicationInvitation users_permission_sets = Table( @@ -39,6 +40,13 @@ class User( "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) dod_id = Column(String, unique=True, nullable=False) first_name = Column(String) diff --git a/atst/routes/applications/team.py b/atst/routes/applications/team.py index c9e57296..7fb75f0e 100644 --- a/atst/routes/applications/team.py +++ b/atst/routes/applications/team.py @@ -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 atst.domain.environments import Environments from atst.domain.applications import Applications 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.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 @@ -47,3 +50,45 @@ def team(application_id): application=application, environment_users=environment_users, ) + + +@applications_bp.route("/application//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", + ) + ) diff --git a/atst/routes/portfolios/invitations.py b/atst/routes/portfolios/invitations.py index a34fcee8..0e5901fe 100644 --- a/atst/routes/portfolios/invitations.py +++ b/atst/routes/portfolios/invitations.py @@ -9,7 +9,9 @@ from atst.models.permissions import Permissions 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( [new_member_email], "{} has invited you to a JEDI Cloud Portfolio".format(owner_name), diff --git a/atst/services/invitation.py b/atst/services/invitation.py index 9d478776..877a3ab7 100644 --- a/atst/services/invitation.py +++ b/atst/services/invitation.py @@ -1,25 +1,26 @@ 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.domain.task_orders import TaskOrders from atst.domain.portfolio_roles import PortfolioRoles +from atst.models import ApplicationRole, PortfolioRole OFFICER_INVITATIONS = { "ko_invite": { "role": "contracting_officer", "subject": "Review a task order", - "template": "emails/invitation.txt", + "template": "emails/portfolio/invitation.txt", }, "cor_invite": { "role": "contracting_officer_representative", "subject": "Help with a task order", - "template": "emails/invitation.txt", + "template": "emails/portfolio/invitation.txt", }, "so_invite": { "role": "security_officer", "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: - def __init__( - self, - inviter, - member, - email, - subject="{} has invited you to a JEDI cloud portfolio", - email_template="emails/invitation.txt", - ): + def __init__(self, inviter, member, email, subject="", email_template=None): self.inviter = inviter self.member = member self.email = email self.subject = subject 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): invite = self._create_invite() self._send_invite_email(invite.token) @@ -68,7 +87,7 @@ class Invitation: return invite 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): body = render_template( diff --git a/atst/utils/flash.py b/atst/utils/flash.py index da021ecc..9fff1be2 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -161,6 +161,13 @@ MESSAGES = { """, "category": "success", }, + "new_application_member": { + "title_template": translate("flash.success"), + "message_template": """ +

{{ "flash.new_application_member" | translate({ "user_name": new_member.user_name }) }}

+ """, + "category": "success", + }, } diff --git a/templates/emails/application/invitation.txt b/templates/emails/application/invitation.txt new file mode 100644 index 00000000..1d49b452 --- /dev/null +++ b/templates/emails/application/invitation.txt @@ -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 %} diff --git a/templates/emails/invitation.txt b/templates/emails/base.txt similarity index 61% rename from templates/emails/invitation.txt rename to templates/emails/base.txt index f63e4db2..8a098d8e 100644 --- a/templates/emails/invitation.txt +++ b/templates/emails/base.txt @@ -1,7 +1,4 @@ -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) }} +{% block content %}{% endblock %} What is JEDI Cloud? JEDI Cloud is a DoD enterprise-wide solution for commercial cloud services. diff --git a/templates/emails/portfolio/invitation.txt b/templates/emails/portfolio/invitation.txt new file mode 100644 index 00000000..dd0f12df --- /dev/null +++ b/templates/emails/portfolio/invitation.txt @@ -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 %} diff --git a/tests/domain/test_applications.py b/tests/domain/test_applications.py index b10c15f3..922b813c 100644 --- a/tests/domain/test_applications.py +++ b/tests/domain/test_applications.py @@ -1,7 +1,9 @@ import pytest from uuid import uuid4 +from atst.models import CSPRole from atst.domain.applications import Applications +from atst.domain.permission_sets import PermissionSets from atst.domain.exceptions import NotFoundError from tests.factories import ( @@ -100,3 +102,29 @@ def test_delete_application(session): # changes are flushed 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 diff --git a/tests/routes/applications/test_team.py b/tests/routes/applications/test_team.py index d5a8e764..1c3e0a17 100644 --- a/tests/routes/applications/test_team.py +++ b/tests/routes/applications/test_team.py @@ -1,6 +1,6 @@ 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): @@ -12,3 +12,43 @@ def test_application_team(client, user_session): response = client.get(url_for("applications.team", application_id=application.id)) 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 diff --git a/tests/services/test_invitation.py b/tests/services/test_invitation.py index 364cdbcc..afa4ddde 100644 --- a/tests/services/test_invitation.py +++ b/tests/services/test_invitation.py @@ -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 -def test_invite_member(queue): +def test_invite_portfolio_member(queue): inviter = UserFactory.create() new_member = UserFactory.create() portfolio = PortfolioFactory.create(owner=inviter) @@ -12,3 +18,14 @@ def test_invite_member(queue): new_invitation = invite_service.invite() assert new_invitation == new_member.portfolio_invitations[0] 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 diff --git a/translations.yaml b/translations.yaml index 43d406f5..7ec5a4e8 100644 --- a/translations.yaml +++ b/translations.yaml @@ -65,6 +65,7 @@ flash: next_steps: Review next steps below portfolio_home: Go to my portfolio home page success: Success! + new_application_member: 'You have successfully invited {user_name} to the team.' footer: about_link_text: Joint Enterprise Defense Infrastructure browser_support: JEDI Cloud supported on these web browsers