Merge pull request #789 from dod-ccpo/application-invitations

Application invitations
This commit is contained in:
dandds 2019-04-30 17:21:10 -04:00 committed by GitHub
commit 3799e5c73f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1082 additions and 371 deletions

View File

@ -0,0 +1,69 @@
"""add application_invitation
Revision ID: 432c5287256d
Revises: 1880551a32e4
Create Date: 2019-04-23 09:23:05.738680
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '432c5287256d'
down_revision = '1880551a32e4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.rename_table('invitations', 'portfolio_invitations')
op.create_table('application_invitations',
sa.Column('time_created', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('time_updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('status', sa.Enum('ACCEPTED', 'REVOKED', 'PENDING', 'REJECTED_WRONG_USER', 'REJECTED_EXPIRED', name='status', native_enum=False), nullable=True),
sa.Column('expiration_time', sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column('token', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=False),
sa.Column('application_role_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('inviter_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.ForeignKeyConstraint(['application_role_id'], ['application_roles.id'], ),
sa.ForeignKeyConstraint(['inviter_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_application_invitations_application_role_id'), 'application_invitations', ['application_role_id'], unique=False)
op.create_index(op.f('ix_application_invitations_inviter_id'), 'application_invitations', ['inviter_id'], unique=False)
op.create_index(op.f('ix_application_invitations_token'), 'application_invitations', ['token'], unique=False)
op.create_index(op.f('ix_application_invitations_user_id'), 'application_invitations', ['user_id'], unique=False)
op.drop_index('ix_invitations_inviter_id', table_name='invitations')
op.drop_index('ix_invitations_portfolio_role_id', table_name='invitations')
op.drop_index('ix_invitations_token', table_name='invitations')
op.drop_index('ix_invitations_user_id', table_name='invitations')
op.create_index(op.f('ix_portfolio_invitations_inviter_id'), 'portfolio_invitations', ['inviter_id'], unique=False)
op.create_index(op.f('ix_portfolio_invitations_portfolio_role_id'), 'portfolio_invitations', ['portfolio_role_id'], unique=False)
op.create_index(op.f('ix_portfolio_invitations_token'), 'portfolio_invitations', ['token'], unique=False)
op.create_index(op.f('ix_portfolio_invitations_user_id'), 'portfolio_invitations', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.rename_table('portfolio_invitations', 'invitations')
op.drop_index(op.f('ix_application_invitations_user_id'), table_name='application_invitations')
op.drop_index(op.f('ix_application_invitations_token'), table_name='application_invitations')
op.drop_index(op.f('ix_application_invitations_inviter_id'), table_name='application_invitations')
op.drop_index(op.f('ix_application_invitations_application_role_id'), table_name='application_invitations')
op.drop_table('application_invitations')
op.drop_index(op.f('ix_portfolio_invitations_user_id'), table_name='portfolio_invitations')
op.drop_index(op.f('ix_portfolio_invitations_token'), table_name='portfolio_invitations')
op.drop_index(op.f('ix_portfolio_invitations_portfolio_role_id'), table_name='portfolio_invitations')
op.drop_index(op.f('ix_portfolio_invitations_inviter_id'), table_name='portfolio_invitations')
op.create_index('ix_invitations_user_id', 'invitations', ['user_id'], unique=False)
op.create_index('ix_invitations_token', 'invitations', ['token'], unique=False)
op.create_index('ix_invitations_portfolio_role_id', 'invitations', ['portfolio_role_id'], unique=False)
op.create_index('ix_invitations_inviter_id', 'invitations', ['inviter_id'], unique=False)
# ### end Alembic commands ###

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

@ -6,8 +6,8 @@ from . import user_can_access
from atst.domain.portfolios import Portfolios from atst.domain.portfolios import Portfolios
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.domain.applications import Applications from atst.domain.applications import Applications
from atst.domain.invitations import Invitations
from atst.domain.environments import Environments from atst.domain.environments import Environments
from atst.domain.invitations import PortfolioInvitations
from atst.domain.exceptions import UnauthorizedError from atst.domain.exceptions import UnauthorizedError
@ -24,8 +24,8 @@ def check_access(permission, message, override, *args, **kwargs):
access_args["portfolio"] = task_order.portfolio access_args["portfolio"] = task_order.portfolio
elif "token" in kwargs: elif "token" in kwargs:
invite = Invitations._get(kwargs["token"]) invite = PortfolioInvitations._get(kwargs["token"])
access_args["portfolio"] = invite.portfolio_role.portfolio access_args["portfolio"] = invite.role.portfolio
elif "portfolio_id" in kwargs: elif "portfolio_id" in kwargs:
access_args["portfolio"] = Portfolios.get( access_args["portfolio"] = Portfolios.get(

View File

@ -2,8 +2,9 @@ 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.invitation import Invitation, Status as InvitationStatus from atst.models import ApplicationInvitation, InvitationStatus, PortfolioInvitation
from atst.domain.portfolio_roles import PortfolioRoles from atst.domain.portfolio_roles import PortfolioRoles
from atst.domain.application_roles import ApplicationRoles
from .exceptions import NotFoundError from .exceptions import NotFoundError
@ -38,27 +39,30 @@ class InvitationError(Exception):
return "{} has a status of {}".format(self.invite.id, self.invite.status.value) return "{} has a status of {}".format(self.invite.id, self.invite.status.value)
class Invitations(object): class BaseInvitations(object):
model = None
role_domain_class = None
# number of minutes a given invitation is considered valid # number of minutes a given invitation is considered valid
EXPIRATION_LIMIT_MINUTES = 360 EXPIRATION_LIMIT_MINUTES = 360
@classmethod @classmethod
def _get(cls, token): def _get(cls, token):
try: try:
invite = db.session.query(Invitation).filter_by(token=token).one() invite = db.session.query(cls.model).filter_by(token=token).one()
except NoResultFound: except NoResultFound:
raise NotFoundError("invite") raise NotFoundError(cls.model.__tablename__)
return invite return invite
@classmethod @classmethod
def create(cls, inviter, portfolio_role, email): def create(cls, inviter, role, email):
invite = Invitation( # pylint: disable=not-callable
portfolio_role=portfolio_role, invite = cls.model(
role=role,
inviter=inviter, inviter=inviter,
user=portfolio_role.user, user=role.user,
status=InvitationStatus.PENDING, status=InvitationStatus.PENDING,
expiration_time=Invitations.current_expiration_time(), expiration_time=cls.current_expiration_time(),
email=email, email=email,
) )
db.session.add(invite) db.session.add(invite)
@ -68,29 +72,29 @@ class Invitations(object):
@classmethod @classmethod
def accept(cls, user, token): def accept(cls, user, token):
invite = Invitations._get(token) invite = cls._get(token)
if invite.user.dod_id != user.dod_id: if invite.user.dod_id != user.dod_id:
if invite.is_pending: if invite.is_pending:
Invitations._update_status(invite, InvitationStatus.REJECTED_WRONG_USER) cls._update_status(invite, InvitationStatus.REJECTED_WRONG_USER)
raise WrongUserError(user, invite) raise WrongUserError(user, invite)
elif invite.is_expired: elif invite.is_expired:
Invitations._update_status(invite, InvitationStatus.REJECTED_EXPIRED) cls._update_status(invite, InvitationStatus.REJECTED_EXPIRED)
raise ExpiredError(invite) raise ExpiredError(invite)
elif invite.is_accepted or invite.is_revoked or invite.is_rejected: elif invite.is_accepted or invite.is_revoked or invite.is_rejected:
raise InvitationError(invite) raise InvitationError(invite)
elif invite.is_pending: # pragma: no branch elif invite.is_pending: # pragma: no branch
Invitations._update_status(invite, InvitationStatus.ACCEPTED) cls._update_status(invite, InvitationStatus.ACCEPTED)
PortfolioRoles.enable(invite.portfolio_role) cls.role_domain_class.enable(invite.role)
return invite return invite
@classmethod @classmethod
def current_expiration_time(cls): def current_expiration_time(cls):
return datetime.datetime.now() + datetime.timedelta( return datetime.datetime.now() + datetime.timedelta(
minutes=Invitations.EXPIRATION_LIMIT_MINUTES minutes=cls.EXPIRATION_LIMIT_MINUTES
) )
@classmethod @classmethod
@ -103,23 +107,31 @@ class Invitations(object):
@classmethod @classmethod
def revoke(cls, token): def revoke(cls, token):
invite = Invitations._get(token) invite = cls._get(token)
return Invitations._update_status(invite, InvitationStatus.REVOKED) return cls._update_status(invite, InvitationStatus.REVOKED)
@classmethod @classmethod
def lookup_by_portfolio_and_user(cls, portfolio, user): def lookup_by_resource_and_user(cls, resource, user):
portfolio_role = PortfolioRoles.get(portfolio.id, user.id) role = cls.role_domain_class.get(resource.id, user.id)
if portfolio_role.latest_invitation is None: if role.latest_invitation is None:
raise NotFoundError("invitation") raise NotFoundError(cls.model.__tablename__)
return portfolio_role.latest_invitation return role.latest_invitation
@classmethod @classmethod
def resend(cls, user, token): def resend(cls, user, token):
previous_invitation = Invitations._get(token) previous_invitation = cls._get(token)
Invitations._update_status(previous_invitation, InvitationStatus.REVOKED) cls._update_status(previous_invitation, InvitationStatus.REVOKED)
return Invitations.create( return cls.create(user, previous_invitation.role, previous_invitation.email)
user, previous_invitation.portfolio_role, previous_invitation.email
)
class PortfolioInvitations(BaseInvitations):
model = PortfolioInvitation
role_domain_class = PortfolioRoles
class ApplicationInvitations(BaseInvitations):
model = ApplicationInvitation
role_domain_class = ApplicationRoles

View File

@ -0,0 +1,54 @@
from flask_wtf import FlaskForm
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
from atst.utils.localization import translate
class EnvironmentForm(FlaskForm):
environment_id = HiddenField()
environment_name = HiddenField()
role = SelectField(
environment_name,
choices=ENV_ROLES,
default=None,
filters=[lambda x: None if x == "None" else x],
)
class PermissionsForm(FlaskForm):
perms_env_mgmt = BooleanField(
translate("portfolios.applications.members.new.manage_envs"), default=False
)
perms_team_mgmt = BooleanField(
translate("portfolios.applications.members.new.manage_team"), default=False
)
perms_del_env = BooleanField(
translate("portfolios.applications.members.new.delete_envs"), 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,5 +1,5 @@
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
SERVICE_BRANCHES = [ SERVICE_BRANCHES = [

27
atst/forms/member.py Normal file
View File

@ -0,0 +1,27 @@
from flask_wtf import FlaskForm
from wtforms.fields.html5 import EmailField, TelField
from wtforms.validators import Required, Email, Length, Optional
from wtforms.fields import StringField
from atst.forms.validators import IsNumber, PhoneNumber
from atst.utils.localization import translate
class NewForm(FlaskForm):
first_name = StringField(
label=translate("forms.new_member.first_name_label"), validators=[Required()]
)
last_name = StringField(
label=translate("forms.new_member.last_name_label"), validators=[Required()]
)
email = EmailField(
translate("forms.new_member.email_label"), validators=[Required(), Email()]
)
phone_number = TelField(
translate("forms.new_member.phone_number_label"),
validators=[Optional(), PhoneNumber()],
)
dod_id = StringField(
translate("forms.new_member.dod_id_label"),
validators=[Required(), Length(min=10), IsNumber()],
)

View File

@ -1,10 +1,9 @@
from wtforms.fields.html5 import EmailField, TelField from wtforms.validators import Required
from wtforms.validators import Required, Email, Length, Optional
from wtforms.fields import StringField, FormField, FieldList, HiddenField from wtforms.fields import StringField, FormField, FieldList, HiddenField
from atst.domain.permission_sets import PermissionSets from atst.domain.permission_sets import PermissionSets
from .forms import BaseForm from .forms import BaseForm
from atst.forms.validators import IsNumber, PhoneNumber from .member import NewForm as BaseNewMemberForm
from atst.forms.fields import SelectField from atst.forms.fields import SelectField
from atst.utils.localization import translate from atst.utils.localization import translate
@ -62,24 +61,16 @@ class EditForm(PermissionsForm):
pass pass
class NewForm(PermissionsForm): class NewForm(BaseForm):
first_name = StringField( user_data = FormField(BaseNewMemberForm)
label=translate("forms.new_member.first_name_label"), validators=[Required()] permission_sets = FormField(PermissionsForm)
)
last_name = StringField( @property
label=translate("forms.new_member.last_name_label"), validators=[Required()] def update_data(self):
) return {
email = EmailField( "permission_sets": self.data.get("permission_sets").get("permission_sets"),
translate("forms.new_member.email_label"), validators=[Required(), Email()] **self.data.get("user_data"),
) }
phone_number = TelField(
translate("forms.new_member.phone_number_label"),
validators=[Optional(), PhoneNumber()],
)
dod_id = StringField(
translate("forms.new_member.dod_id_label"),
validators=[Required(), Length(min=10), IsNumber()],
)
class AssignPPOCForm(PermissionsForm): class AssignPPOCForm(PermissionsForm):

View File

@ -5,14 +5,17 @@ Base = declarative_base()
from .permissions import Permissions from .permissions import Permissions
from .permission_set import PermissionSet from .permission_set import PermissionSet
from .user import User from .user import User
from .portfolio_role import PortfolioRole from .portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
from .application_role import ApplicationRole 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
from .attachment import Attachment from .attachment import Attachment
from .audit_event import AuditEvent from .audit_event import AuditEvent
from .invitation import Invitation from .portfolio_invitation import PortfolioInvitation
from .application_invitation import ApplicationInvitation
from .task_order import TaskOrder from .task_order import TaskOrder
from .dd_254 import DD254 from .dd_254 import DD254
from .mixins.invites import Status as InvitationStatus

View File

@ -0,0 +1,41 @@
from sqlalchemy import Column, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, backref
from atst.models import Base
from atst.models.mixins import TimestampsMixin, AuditableMixin, InvitesMixin
class ApplicationInvitation(Base, TimestampsMixin, AuditableMixin, InvitesMixin):
__tablename__ = "application_invitations"
application_role_id = Column(
UUID(as_uuid=True), ForeignKey("application_roles.id"), index=True
)
role = relationship(
"ApplicationRole",
backref=backref("invitations", order_by="ApplicationInvitation.time_created"),
)
@property
def application(self):
if self.role: # pragma: no branch
return self.role.application
@property
def application_id(self):
return self.role.application_id
@property
def event_details(self):
return {"email": self.email, "dod_id": self.user_dod_id}
@property
def history(self):
changes = self.get_changes()
change_set = {}
if "status" in changes:
change_set["status"] = [s.name for s in changes["status"]]
return change_set

View File

@ -51,6 +51,10 @@ class ApplicationRole(
"PermissionSet", secondary=application_roles_permission_sets "PermissionSet", secondary=application_roles_permission_sets
) )
@property
def user_name(self):
return self.user.full_name
def __repr__(self): def __repr__(self):
return "<ApplicationRole(application='{}', user_id='{}', id='{}', permissions={})>".format( return "<ApplicationRole(application='{}', user_id='{}', id='{}', permissions={})>".format(
self.application.name, self.user_id, self.id, self.permissions self.application.name, self.user_id, self.id, self.permissions

View File

@ -2,3 +2,4 @@ from .timestamps import TimestampsMixin
from .auditable import AuditableMixin from .auditable import AuditableMixin
from .permissions import PermissionsMixin from .permissions import PermissionsMixin
from .deletable import DeletableMixin from .deletable import DeletableMixin
from .invites import InvitesMixin

View File

@ -3,12 +3,11 @@ from enum import Enum
import secrets import secrets
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum, TIMESTAMP, String from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum, TIMESTAMP, String
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, backref from sqlalchemy.orm import relationship
from atst.models import Base, types from atst.models import types
from atst.models.mixins.timestamps import TimestampsMixin
from atst.models.mixins.auditable import AuditableMixin
class Status(Enum): class Status(Enum):
@ -19,24 +18,24 @@ class Status(Enum):
REJECTED_EXPIRED = "rejected_expired" REJECTED_EXPIRED = "rejected_expired"
class Invitation(Base, TimestampsMixin, AuditableMixin): class InvitesMixin(object):
__tablename__ = "invitations"
id = types.Id() id = types.Id()
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) @declared_attr
user = relationship("User", backref="invitations", foreign_keys=[user_id]) def user_id(cls):
return Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
portfolio_role_id = Column( @declared_attr
UUID(as_uuid=True), ForeignKey("portfolio_roles.id"), index=True def user(cls):
) return relationship("User", foreign_keys=[cls.user_id])
portfolio_role = relationship(
"PortfolioRole",
backref=backref("invitations", order_by="Invitation.time_created"),
)
inviter_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) @declared_attr
inviter = relationship("User", backref="sent_invites", foreign_keys=[inviter_id]) def inviter_id(cls):
return Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
@declared_attr
def inviter(cls):
return relationship("User", foreign_keys=[cls.inviter_id])
status = Column(SQLAEnum(Status, native_enum=False, default=Status.PENDING)) status = Column(SQLAEnum(Status, native_enum=False, default=Status.PENDING))
@ -47,8 +46,9 @@ class Invitation(Base, TimestampsMixin, AuditableMixin):
email = Column(String, nullable=False) email = Column(String, nullable=False)
def __repr__(self): def __repr__(self):
return "<Invitation(user='{}', portfolio_role='{}', id='{}', email='{}')>".format( role_id = self.role.id if self.role else None
self.user_id, self.portfolio_role_id, self.id, self.email return "<{}(user='{}', role='{}', id='{}', email='{}')>".format(
self.__class__.__name__, self.user_id, role_id, self.id, self.email
) )
@property @property
@ -90,14 +90,9 @@ class Invitation(Base, TimestampsMixin, AuditableMixin):
Status.REVOKED, Status.REVOKED,
] ]
@property
def portfolio(self):
if self.portfolio_role: # pragma: no branch
return self.portfolio_role.portfolio
@property @property
def user_name(self): def user_name(self):
return self.portfolio_role.user.full_name return self.role.user.full_name
@property @property
def is_revokable(self): def is_revokable(self):
@ -110,21 +105,3 @@ class Invitation(Base, TimestampsMixin, AuditableMixin):
@property @property
def user_dod_id(self): def user_dod_id(self):
return self.user.dod_id if self.user is not None else None return self.user.dod_id if self.user is not None else None
@property
def event_details(self):
return {"email": self.email, "dod_id": self.user_dod_id}
@property
def history(self):
changes = self.get_changes()
change_set = {}
if "status" in changes:
change_set["status"] = [s.name for s in changes["status"]]
return change_set
@property
def portfolio_id(self):
return self.portfolio_role.portfolio_id

View File

@ -0,0 +1,41 @@
from sqlalchemy import Column, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship, backref
from atst.models import Base
from atst.models.mixins import TimestampsMixin, AuditableMixin, InvitesMixin
class PortfolioInvitation(Base, TimestampsMixin, AuditableMixin, InvitesMixin):
__tablename__ = "portfolio_invitations"
portfolio_role_id = Column(
UUID(as_uuid=True), ForeignKey("portfolio_roles.id"), index=True
)
role = relationship(
"PortfolioRole",
backref=backref("invitations", order_by="PortfolioInvitation.time_created"),
)
@property
def portfolio(self):
if self.role: # pragma: no branch
return self.role.portfolio
@property
def portfolio_id(self):
return self.role.portfolio_id
@property
def event_details(self):
return {"email": self.email, "dod_id": self.user_dod_id}
@property
def history(self):
changes = self.get_changes()
change_set = {}
if "status" in changes:
change_set["status"] = [s.name for s in changes["status"]]
return change_set

View File

@ -4,6 +4,8 @@ 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.application_invitation import ApplicationInvitation
users_permission_sets = Table( users_permission_sets = Table(
@ -31,6 +33,20 @@ class User(
primaryjoin="and_(ApplicationRole.user_id==User.id, ApplicationRole.deleted==False)", primaryjoin="and_(ApplicationRole.user_id==User.id, ApplicationRole.deleted==False)",
) )
portfolio_invitations = relationship(
"PortfolioInvitation", foreign_keys=PortfolioInvitation.user_id
)
sent_portfolio_invitations = relationship(
"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,16 @@
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.domain.exceptions import AlreadyExistsError
from atst.forms.application_member import NewForm as NewMemberForm
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.localization import translate from atst.utils.localization import translate
@ -42,8 +46,57 @@ def team(application_id):
), ),
} }
env_roles = [
{"environment_id": e.id, "environment_name": e.name}
for e in application.environments
]
member_form = NewMemberForm(data={"environment_roles": env_roles})
return render_template( return render_template(
"portfolios/applications/team.html", "portfolios/applications/team.html",
application=application, application=application,
environment_users=environment_users, environment_users=environment_users,
member_form=member_form,
)
@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

@ -1,7 +1,7 @@
from flask import g, redirect, url_for, render_template from flask import g, redirect, url_for, render_template
from . import portfolios_bp from . import portfolios_bp
from atst.domain.invitations import Invitations from atst.domain.invitations import PortfolioInvitations
from atst.queue import queue from atst.queue import queue
from atst.utils.flash import formatted_flash as flash from atst.utils.flash import formatted_flash as flash
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
@ -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),
@ -19,7 +21,7 @@ def send_invite_email(owner_name, token, new_member_email):
@portfolios_bp.route("/portfolios/invitations/<token>", methods=["GET"]) @portfolios_bp.route("/portfolios/invitations/<token>", methods=["GET"])
def accept_invitation(token): def accept_invitation(token):
invite = Invitations.accept(g.current_user, token) invite = PortfolioInvitations.accept(g.current_user, token)
for task_order in invite.portfolio.task_orders: for task_order in invite.portfolio.task_orders:
if g.current_user in task_order.officers: if g.current_user in task_order.officers:
@ -37,7 +39,7 @@ def accept_invitation(token):
) )
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="revoke invitation") @user_can(Permissions.EDIT_PORTFOLIO_USERS, message="revoke invitation")
def revoke_invitation(portfolio_id, token): def revoke_invitation(portfolio_id, token):
Invitations.revoke(token) PortfolioInvitations.revoke(token)
return redirect( return redirect(
url_for( url_for(
@ -54,7 +56,7 @@ def revoke_invitation(portfolio_id, token):
) )
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="resend invitation") @user_can(Permissions.EDIT_PORTFOLIO_USERS, message="resend invitation")
def resend_invitation(portfolio_id, token): def resend_invitation(portfolio_id, token):
invite = Invitations.resend(g.current_user, token) invite = PortfolioInvitations.resend(g.current_user, token)
send_invite_email(g.current_user.full_name, invite.token, invite.email) send_invite_email(g.current_user.full_name, invite.token, invite.email)
flash("resend_portfolio_invitation", user_name=invite.user_name) flash("resend_portfolio_invitation", user_name=invite.user_name)
return redirect( return redirect(

View File

@ -34,9 +34,9 @@ def create_member(portfolio_id):
if form.validate(): if form.validate():
try: try:
member = Portfolios.create_member(portfolio, form.data) member = Portfolios.create_member(portfolio, form.update_data)
invite_service = InvitationService( invite_service = InvitationService(
g.current_user, member, form.data.get("email") g.current_user, member, form.update_data.get("email")
) )
invite_service.invite() invite_service.invite()

View File

@ -7,7 +7,7 @@ from atst.domain.authz.decorator import user_can_access_decorator as user_can
from atst.models.permissions import Permissions from atst.models.permissions import Permissions
from atst.database import db from atst.database import db
from atst.domain.exceptions import NotFoundError, NoAccessError from atst.domain.exceptions import NotFoundError, NoAccessError
from atst.domain.invitations import Invitations from atst.domain.invitations import PortfolioInvitations
from atst.domain.portfolios import Portfolios from atst.domain.portfolios import Portfolios
from atst.utils.localization import translate from atst.utils.localization import translate
from atst.forms.officers import EditTaskOrderOfficersForm from atst.forms.officers import EditTaskOrderOfficersForm
@ -57,7 +57,7 @@ def resend_invite(task_order_id):
if not officer: if not officer:
raise NotFoundError("officer") raise NotFoundError("officer")
invitation = Invitations.lookup_by_portfolio_and_user(portfolio, officer) invitation = PortfolioInvitations.lookup_by_resource_and_user(portfolio, officer)
if not invitation: if not invitation:
raise NotFoundError("invitation") raise NotFoundError("invitation")
@ -65,11 +65,11 @@ def resend_invite(task_order_id):
if not invitation.can_resend: if not invitation.can_resend:
raise NoAccessError("invitation") raise NoAccessError("invitation")
Invitations.revoke(token=invitation.token) PortfolioInvitations.revoke(token=invitation.token)
invite_service = InvitationService( invite_service = InvitationService(
g.current_user, g.current_user,
invitation.portfolio_role, invitation.role,
invitation.email, invitation.email,
subject=invite_type_info["subject"], subject=invite_type_info["subject"],
email_template=invite_type_info["template"], email_template=invite_type_info["template"],

View File

@ -1,25 +1,26 @@
from flask import render_template from flask import render_template
from atst.domain.invitations import Invitations 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,30 @@ 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 = (
self.email_template or "emails/portfolio/invitation.txt"
)
self.subject = (
self.subject or "{} has invited you to a JEDI cloud portfolio"
)
self.domain_class = PortfolioInvitations
elif isinstance(member, ApplicationRole):
self.email_template = (
self.email_template or "emails/application/invitation.txt"
)
self.subject = (
self.subject or "{} has invited you to a JEDI cloud application"
)
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 +79,7 @@ class Invitation:
return invite return invite
def _create_invite(self): def _create_invite(self):
return Invitations.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

@ -1,10 +1,22 @@
import { emitEvent } from '../lib/emitters' import { emitEvent } from '../lib/emitters'
import nestedcheckboxinput from './nested_checkbox_input'
export default { export default {
name: 'checkboxinput', name: 'checkboxinput',
components: {
nestedcheckboxinput,
},
props: { props: {
name: String, name: String,
initialChecked: Boolean,
},
data: function() {
return {
isChecked: this.initialChecked,
}
}, },
methods: { methods: {

View File

@ -1,6 +1,7 @@
import FormMixin from '../../mixins/form' import FormMixin from '../../mixins/form'
import textinput from '../text_input' import textinput from '../text_input'
import optionsinput from '../options_input' import optionsinput from '../options_input'
import checkboxinput from '../checkbox_input'
import Selector from '../selector' import Selector from '../selector'
import Modal from '../../mixins/modal' import Modal from '../../mixins/modal'
import toggler from '../toggler' import toggler from '../toggler'
@ -16,6 +17,7 @@ export default {
Selector, Selector,
textinput, textinput,
optionsinput, optionsinput,
checkboxinput,
}, },
props: { props: {
@ -45,6 +47,9 @@ export default {
this.step += 1 this.step += 1
} }
}, },
previous: function() {
this.step -= 1
},
goToStep: function(step) { goToStep: function(step) {
if (this._checkIsValid()) { if (this._checkIsValid()) {
this.step = step this.step = step

View File

@ -0,0 +1,31 @@
import { emitEvent } from '../lib/emitters'
export default {
name: 'nestedcheckboxinput',
props: {
name: String,
isParentChecked: Boolean,
},
data: function() {
return {
isChecked: false,
}
},
updated: function() {
if (!this.isParentChecked) {
this.isChecked = false
}
},
methods: {
onInput: function(e) {
emitEvent('field-change', this, {
value: e.target.checked,
name: this.name,
})
},
},
}

View File

@ -18,6 +18,7 @@ export default {
showError: showError, showError: showError,
showValid: !showError && !!this.initialValue, showValid: !showError && !!this.initialValue,
validationError: this.initialErrors.join(' '), validationError: this.initialErrors.join(' '),
value: this.initialValue,
} }
}, },

View File

@ -11,6 +11,7 @@ import optionsinput from './components/options_input'
import multicheckboxinput from './components/multi_checkbox_input' import multicheckboxinput from './components/multi_checkbox_input'
import textinput from './components/text_input' import textinput from './components/text_input'
import checkboxinput from './components/checkbox_input' import checkboxinput from './components/checkbox_input'
import nestedcheckboxinput from './components/nested_checkbox_input'
import EditOfficerForm from './components/forms/edit_officer_form' import EditOfficerForm from './components/forms/edit_officer_form'
import poc from './components/forms/poc' import poc from './components/forms/poc'
import oversight from './components/forms/oversight' import oversight from './components/forms/oversight'
@ -74,6 +75,7 @@ const app = new Vue({
KoReview, KoReview,
BaseForm, BaseForm,
DeleteConfirmation, DeleteConfirmation,
nestedcheckboxinput,
}, },
mounted: function() { mounted: function() {

View File

@ -35,7 +35,7 @@ export default {
}, },
email: { email: {
mask: emailMask, mask: emailMask,
match: /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/i, match: /^.+@[^.].*\.[a-z]{2,10}$/,
unmask: [], unmask: [],
validationError: 'Please enter a valid e-mail address', validationError: 'Please enter a valid e-mail address',
}, },

View File

@ -46,6 +46,14 @@
flex-basis: 16.66%; flex-basis: 16.66%;
} }
&.form-col--quarter {
flex-basis: 25%;
}
&.form-col--three-quarters {
flex-basis: 75%;
}
.usa-input { .usa-input {
margin-left: ($gap * 4); margin-left: ($gap * 4);
margin-right: ($gap * 4); margin-right: ($gap * 4);
@ -171,3 +179,15 @@
} }
} }
.input__inline-fields {
margin: 1rem 0;
&.input__inline-fields--indented {
margin-left: $gap*4;
}
&> fieldset.usa-input__choices label {
display: inline;
font-weight: $font-normal;
}
}

View File

@ -43,6 +43,7 @@ body {
background-color: $color-white; background-color: $color-white;
padding: $gap * 2; padding: $gap * 2;
width: 100%; width: 100%;
border-radius: 5px;
overflow-y: auto; overflow-y: auto;
-ms-overflow-style: scrollbar; -ms-overflow-style: scrollbar;
@ -52,7 +53,7 @@ body {
} }
@include media($medium-screen) { @include media($medium-screen) {
padding: $gap * 4; padding: $gap * 5;
} }
h1, h2 { h1, h2 {
@ -186,6 +187,7 @@ body {
.form-row { .form-row {
margin-top: 0; margin-top: 0;
margin-bottom: 0;
.form-col { .form-col {
.usa-input { .usa-input {

View File

@ -21,4 +21,9 @@
&:last-child { &:last-child {
margin-bottom: $gap * 3; margin-bottom: $gap * 3;
} }
.action-group__action--left {
margin-left: 0;
margin-right: auto;
}
} }

View File

@ -59,3 +59,34 @@
} }
} }
} }
.environment-roles-new {
margin-top: 5*$gap;
margin-bottom: 8*$gap;
.usa-input {
margin: 2rem 0 2rem 0;
.usa-input__title-inline {
line-height: $hit-area;
}
legend {
font-size: $lead-font-size;
padding: 0;
}
}
.form-row {
margin: 0;
}
}
.environment-roles-new__head {
font-weight: $font-bold;
}
.environment-name--gray {
font-weight: $font-normal;
color: $color-gray-medium;
}

View File

@ -1,17 +1,21 @@
{% macro CheckboxInput( {% macro CheckboxInput(
field, field,
label=field.label | striptags, label=field.label,
inline=False, inline=False,
classes="") -%} classes="") -%}
<checkboxinput name='{{ field.name }}' inline-template key='{{ field.name }}'> <checkboxinput
name='{{ field.name }}'
inline-template
key='{{ field.name }}'
v-bind:initial-checked='{{ field.data|string|lower }}'
>
<div>
<div class='usa-input {{ classes }} {% if field.errors %}usa-input--error{% endif %}'> <div class='usa-input {{ classes }} {% if field.errors %}usa-input--error{% endif %}'>
<fieldset data-ally-disabled="true" v-on:change="onInput" class="usa-input__choices {% if inline %}usa-input__choices--inline{% endif %}"> <fieldset data-ally-disabled="true" v-on:change="onInput" class="usa-input__choices {% if inline %}usa-input__choices--inline{% endif %}">
<legend> <legend>
{{ field() }} {{ field(**{"v-model": "isChecked"}) }}
<label for={{field.name}}> {{ label | safe }}
{{ label }}
</label>
{% if field.description %} {% if field.description %}
<span class='usa-input__help'>{{ field.description | safe }}</span> <span class='usa-input__help'>{{ field.description | safe }}</span>
@ -19,5 +23,9 @@
</legend> </legend>
</fieldset> </fieldset>
</div> </div>
{% if caller %}
{{ caller() }}
{% endif %}
</div>
</checkboxinput> </checkboxinput>
{%- endmacro %} {%- endmacro %}

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

@ -21,23 +21,23 @@
</div> </div>
<div class='form-row'> <div class='form-row'>
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(member_form.first_name, validation='requiredField') }} {{ TextInput(member_form.user_data.first_name, validation='requiredField') }}
</div> </div>
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(member_form.last_name, validation='requiredField') }} {{ TextInput(member_form.user_data.last_name, validation='requiredField') }}
</div> </div>
</div> </div>
<div class='form-row'> <div class='form-row'>
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(member_form.email, validation='email') }} {{ TextInput(member_form.user_data.email, validation='email') }}
</div> </div>
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(member_form.phone_number, validation='usPhone', optional=True) }} {{ TextInput(member_form.user_data.phone_number, validation='usPhone', optional=True) }}
</div> </div>
</div> </div>
<div class='form-row'> <div class='form-row'>
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(member_form.dod_id, validation='dodId') }} {{ TextInput(member_form.user_data.dod_id, validation='dodId') }}
</div> </div>
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
</div> </div>
@ -61,10 +61,10 @@
{{ "portfolios.admin.permissions_info" | translate }} {{ "portfolios.admin.permissions_info" | translate }}
</a> </a>
</div> </div>
{{ SimpleOptionsInput(member_form.perms_app_mgmt) }} {{ SimpleOptionsInput(member_form.permission_sets.perms_app_mgmt) }}
{{ SimpleOptionsInput(member_form.perms_funding) }} {{ SimpleOptionsInput(member_form.permission_sets.perms_funding) }}
{{ SimpleOptionsInput(member_form.perms_reporting) }} {{ SimpleOptionsInput(member_form.permission_sets.perms_reporting) }}
{{ SimpleOptionsInput(member_form.perms_portfolio_mgmt) }} {{ SimpleOptionsInput(member_form.permission_sets.perms_portfolio_mgmt) }}
<div class='action-group'> <div class='action-group'>
<input <input
type="submit" type="submit"

View File

@ -0,0 +1,136 @@
{% from "components/icon.html" import Icon %}
{% from "components/text_input.html" import TextInput %}
{% from "components/checkbox_input.html" import CheckboxInput %}
{% from "components/multi_step_modal_form.html" import MultiStepModalForm %}
{% set step_one %}
<div class="modal__form--header">
<h1>Invite new member</h1>
</div>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(member_form.user_data.first_name, validation='requiredField') }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(member_form.user_data.last_name, validation='requiredField') }}
</div>
</div>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(member_form.user_data.email, validation='email') }}
</div>
<div class='form-col form-col--half'>
{{ TextInput(member_form.user_data.phone_number, validation='usPhone', optional=True) }}
</div>
</div>
<div class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(member_form.user_data.dod_id, validation='dodId') }}
</div>
<div class='form-col form-col--half'>
</div>
</div>
<div class='action-group'>
<input
type='button'
v-on:click="next()"
v-bind:disabled="invalid"
class='action-group__action usa-button'
value='Next'>
<a class='action-group__action icon-link icon-link--default' v-on:click="closeModal('{{ new_port_mem }}')">{{ "common.cancel" | translate }}</a>
</div>
{% endset %}
{% set step_two %}
<div class="modal__form">
<div class="modal__form--header">
<h1>{{ "portfolios.applications.members.new.assign_roles" | translate }}</h1>
<a class='icon-link'>
{{ Icon('info') }}
{{ "portfolios.applications.members.new.learn_more" | translate }}
</a>
<div class="environment-roles-new">
<div class="form-row">
<div class="form-col form-col--quarter">
<span class="environment-roles-new__head">
{{ "common.resource_names.environments" | translate }}:
</span>
</div>
<div class="form-col form-col--three-quarters">
<span class="environment-roles-new__head">
{{ "common.choose_role" | translate }}:
</span>
</div>
</div>
{% for environment_data in member_form.environment_roles %}
<optionsinput inline-template
v-bind:initial-value="'{{ environment_data.role.data | string }}'"
>
<div class="usa-input">
<fieldset data-ally-disabled="true" class="usa-input__choices">
<div class="form-row">
<div class="form-col form-col--quarter">
<legend>
<div v-bind:class='["usa-input__title-inline", {"environment-name--gray": value === "None" }]'>
{{ environment_data.environment_name.data }}
</div>
</legend>
</div>
<div class="form-col form-col--three-quarters">
{{ environment_data.role(**{"v-model": "value"}) }}
</div>
</div>
</fieldset>
</div>
</optionsinput>
{{ environment_data.environment_id() }}
{% endfor %}
</div>
<h1>{{ "portfolios.applications.members.new.manage_perms" | translate({"application_name": application.name}) }}</h1>
{{ CheckboxInput(member_form.permission_sets.perms_team_mgmt, classes="input__inline-fields") }}
{% call CheckboxInput(member_form.permission_sets.perms_env_mgmt, classes="input__inline-fields") %}
{% set field=member_form.permission_sets.perms_del_env %}
<nestedcheckboxinput
name='{{ field.name }}'
inline-template
key='{{ field.name }}'
v-bind:is-parent-checked="isChecked"
>
<div class="usa-input input__inline-fields input__inline-fields--indented">
<fieldset data-ally-disabled="true" class="usa-input__choices usa-input__choices--inline">
<legend>
<input
id="permission_sets-perms_del_env"
name="permission_sets-perms_del_env"
type="checkbox"
v-model="isChecked"
v-bind:disabled="!$parent.isChecked">
{{ field.label | safe }}
</legend>
</fieldset>
</div>
</checkboxinput>
{% endcall %}
</div>
<div class='action-group'>
<input
type="submit"
class='action-group__action usa-button'
form="add-app-mem"
value='Invite member'>
<a class='action-group__action icon-link icon-link--default' v-on:click="closeModal('{{ new_port_mem }}')">{{ "common.cancel" | translate }}</a>
<input
type='button'
v-on:click="previous()"
class='action-group__action usa-button action-group__action--left'
value='Previous step'>
</div>
</div>
{% endset %}
{{ MultiStepModalForm(
'add-app-mem',
member_form,
url_for("applications.create_member", application_id=application.id),
[step_one, step_two],
button_text=("portfolios.admin.add_new_member" | translate),
button_icon="plus-circle-solid",
) }}

View File

@ -28,7 +28,6 @@
{% if g.matchesPath("application-members") %} {% if g.matchesPath("application-members") %}
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
{% endif %} {% endif %}
<form>
<header> <header>
<div class="responsive-table-wrapper__header"> <div class="responsive-table-wrapper__header">
<div class="responsive-table-wrapper__title"> <div class="responsive-table-wrapper__title">
@ -115,6 +114,9 @@
<div class="members-table-footer"> <div class="members-table-footer">
<div class="action-group save"> <div class="action-group save">
{% if user_can(permissions.CREATE_APPLICATION_MEMBER) %}
{% include "fragments/applications/add_new_application_member.html" %}
{% endif %}
</div> </div>
</div> </div>
</form> </form>

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

@ -3,33 +3,32 @@ import pytest
import re import re
from atst.domain.invitations import ( from atst.domain.invitations import (
Invitations, PortfolioInvitations,
InvitationError, InvitationError,
WrongUserError, WrongUserError,
ExpiredError, ExpiredError,
NotFoundError, NotFoundError,
) )
from atst.models.invitation import Status from atst.domain.audit_log import AuditLog
from atst.models import InvitationStatus
from tests.factories import ( from tests.factories import (
PortfolioFactory, PortfolioFactory,
PortfolioRoleFactory, PortfolioRoleFactory,
UserFactory, UserFactory,
InvitationFactory, PortfolioInvitationFactory,
) )
from atst.domain.audit_log import AuditLog
def test_create_invitation(): def test_create_invitation():
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
user = UserFactory.create() user = UserFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
invite = Invitations.create(portfolio.owner, ws_role, user.email) invite = PortfolioInvitations.create(portfolio.owner, ws_role, user.email)
assert invite.user == user assert invite.user == user
assert invite.portfolio_role == ws_role assert invite.role == ws_role
assert invite.inviter == portfolio.owner assert invite.inviter == portfolio.owner
assert invite.status == Status.PENDING assert invite.status == InvitationStatus.PENDING
assert re.match(r"^[\w\-_]+$", invite.token) assert re.match(r"^[\w\-_]+$", invite.token)
@ -37,9 +36,9 @@ def test_accept_invitation():
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
user = UserFactory.create() user = UserFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
invite = Invitations.create(portfolio.owner, ws_role, user.email) invite = PortfolioInvitations.create(portfolio.owner, ws_role, user.email)
assert invite.is_pending assert invite.is_pending
accepted_invite = Invitations.accept(user, invite.token) accepted_invite = PortfolioInvitations.accept(user, invite.token)
assert accepted_invite.is_accepted assert accepted_invite.is_accepted
@ -47,16 +46,16 @@ def test_accept_expired_invitation():
user = UserFactory.create() user = UserFactory.create()
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
increment = Invitations.EXPIRATION_LIMIT_MINUTES + 1 increment = PortfolioInvitations.EXPIRATION_LIMIT_MINUTES + 1
expiration_time = datetime.datetime.now() - datetime.timedelta(minutes=increment) expiration_time = datetime.datetime.now() - datetime.timedelta(minutes=increment)
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
user=user, user=user,
expiration_time=expiration_time, expiration_time=expiration_time,
status=Status.PENDING, status=InvitationStatus.PENDING,
portfolio_role=ws_role, role=ws_role,
) )
with pytest.raises(ExpiredError): with pytest.raises(ExpiredError):
Invitations.accept(user, invite.token) PortfolioInvitations.accept(user, invite.token)
assert invite.is_rejected assert invite.is_rejected
@ -65,22 +64,22 @@ def test_accept_rejected_invite():
user = UserFactory.create() user = UserFactory.create()
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
user=user, status=Status.REJECTED_EXPIRED, portfolio_role=ws_role user=user, status=InvitationStatus.REJECTED_EXPIRED, role=ws_role
) )
with pytest.raises(InvitationError): with pytest.raises(InvitationError):
Invitations.accept(user, invite.token) PortfolioInvitations.accept(user, invite.token)
def test_accept_revoked_invite(): def test_accept_revoked_invite():
user = UserFactory.create() user = UserFactory.create()
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
user=user, status=Status.REVOKED, portfolio_role=ws_role user=user, status=InvitationStatus.REVOKED, role=ws_role
) )
with pytest.raises(InvitationError): with pytest.raises(InvitationError):
Invitations.accept(user, invite.token) PortfolioInvitations.accept(user, invite.token)
def test_wrong_user_accepts_invitation(): def test_wrong_user_accepts_invitation():
@ -88,9 +87,9 @@ def test_wrong_user_accepts_invitation():
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
wrong_user = UserFactory.create() wrong_user = UserFactory.create()
invite = InvitationFactory.create(user=user, portfolio_role=ws_role) invite = PortfolioInvitationFactory.create(user=user, role=ws_role)
with pytest.raises(WrongUserError): with pytest.raises(WrongUserError):
Invitations.accept(wrong_user, invite.token) PortfolioInvitations.accept(wrong_user, invite.token)
def test_user_cannot_accept_invitation_accepted_by_wrong_user(): def test_user_cannot_accept_invitation_accepted_by_wrong_user():
@ -98,30 +97,30 @@ def test_user_cannot_accept_invitation_accepted_by_wrong_user():
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
wrong_user = UserFactory.create() wrong_user = UserFactory.create()
invite = InvitationFactory.create(user=user, portfolio_role=ws_role) invite = PortfolioInvitationFactory.create(user=user, role=ws_role)
with pytest.raises(WrongUserError): with pytest.raises(WrongUserError):
Invitations.accept(wrong_user, invite.token) PortfolioInvitations.accept(wrong_user, invite.token)
with pytest.raises(InvitationError): with pytest.raises(InvitationError):
Invitations.accept(user, invite.token) PortfolioInvitations.accept(user, invite.token)
def test_accept_invitation_twice(): def test_accept_invitation_twice():
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
user = UserFactory.create() user = UserFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
invite = Invitations.create(portfolio.owner, ws_role, user.email) invite = PortfolioInvitations.create(portfolio.owner, ws_role, user.email)
Invitations.accept(user, invite.token) PortfolioInvitations.accept(user, invite.token)
with pytest.raises(InvitationError): with pytest.raises(InvitationError):
Invitations.accept(user, invite.token) PortfolioInvitations.accept(user, invite.token)
def test_revoke_invitation(): def test_revoke_invitation():
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
user = UserFactory.create() user = UserFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
invite = Invitations.create(portfolio.owner, ws_role, user.email) invite = PortfolioInvitations.create(portfolio.owner, ws_role, user.email)
assert invite.is_pending assert invite.is_pending
Invitations.revoke(invite.token) PortfolioInvitations.revoke(invite.token)
assert invite.is_revoked assert invite.is_revoked
@ -129,8 +128,8 @@ def test_resend_invitation():
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
user = UserFactory.create() user = UserFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
invite = Invitations.create(portfolio.owner, ws_role, user.email) invite = PortfolioInvitations.create(portfolio.owner, ws_role, user.email)
Invitations.resend(user, invite.token) PortfolioInvitations.resend(user, invite.token)
assert ws_role.invitations[0].is_revoked assert ws_role.invitations[0].is_revoked
assert ws_role.invitations[1].is_pending assert ws_role.invitations[1].is_pending
@ -139,8 +138,8 @@ def test_audit_event_for_accepted_invite():
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
user = UserFactory.create() user = UserFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
invite = Invitations.create(portfolio.owner, ws_role, user.email) invite = PortfolioInvitations.create(portfolio.owner, ws_role, user.email)
invite = Invitations.accept(user, invite.token) invite = PortfolioInvitations.accept(user, invite.token)
accepted_event = AuditLog.get_by_resource(invite.id)[0] accepted_event = AuditLog.get_by_resource(invite.id)[0]
assert "email" in accepted_event.event_details assert "email" in accepted_event.event_details
@ -151,9 +150,11 @@ def test_lookup_by_user_and_portfolio():
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
user = UserFactory.create() user = UserFactory.create()
ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio) ws_role = PortfolioRoleFactory.create(user=user, portfolio=portfolio)
invite = Invitations.create(portfolio.owner, ws_role, user.email) invite = PortfolioInvitations.create(portfolio.owner, ws_role, user.email)
assert Invitations.lookup_by_portfolio_and_user(portfolio, user) == invite assert PortfolioInvitations.lookup_by_resource_and_user(portfolio, user) == invite
with pytest.raises(NotFoundError): with pytest.raises(NotFoundError):
Invitations.lookup_by_portfolio_and_user(portfolio, UserFactory.create()) PortfolioInvitations.lookup_by_resource_and_user(
portfolio, UserFactory.create()
)

View File

@ -7,7 +7,7 @@ from atst.models.portfolio_role import Status as PortfolioRoleStatus
from tests.factories import ( from tests.factories import (
PortfolioFactory, PortfolioFactory,
UserFactory, UserFactory,
InvitationFactory, PortfolioInvitationFactory,
PortfolioRoleFactory, PortfolioRoleFactory,
) )

View File

@ -7,11 +7,8 @@ import datetime
from atst.forms import data from atst.forms import data
from atst.models import * from atst.models import *
from atst.models.portfolio_role import Status as PortfolioRoleStatus
from atst.models.application_role import Status as ApplicationRoleStatus from atst.domain.invitations import PortfolioInvitations
from atst.models.invitation import Status as InvitationStatus
from atst.models.environment_role import CSPRole
from atst.domain.invitations import Invitations
from atst.domain.permission_sets import PermissionSets from atst.domain.permission_sets import PermissionSets
from atst.domain.portfolio_roles import PortfolioRoles from atst.domain.portfolio_roles import PortfolioRoles
@ -240,13 +237,22 @@ class EnvironmentRoleFactory(Base):
user = factory.SubFactory(UserFactory) user = factory.SubFactory(UserFactory)
class InvitationFactory(Base): class PortfolioInvitationFactory(Base):
class Meta: class Meta:
model = Invitation model = PortfolioInvitation
email = factory.Faker("email") email = factory.Faker("email")
status = InvitationStatus.PENDING status = InvitationStatus.PENDING
expiration_time = Invitations.current_expiration_time() expiration_time = PortfolioInvitations.current_expiration_time()
class ApplicationInvitationFactory(Base):
class Meta:
model = ApplicationInvitation
email = factory.Faker("email")
status = InvitationStatus.PENDING
expiration_time = PortfolioInvitations.current_expiration_time()
class AttachmentFactory(Base): class AttachmentFactory(Base):

View File

@ -1,11 +1,9 @@
import pytest
import datetime import datetime
from atst.models.invitation import Invitation, Status from atst.models import InvitationStatus, PortfolioRoleStatus
from atst.models.portfolio_role import Status as PortfolioRoleStatus
from tests.factories import ( from tests.factories import (
InvitationFactory, PortfolioInvitationFactory,
PortfolioFactory, PortfolioFactory,
UserFactory, UserFactory,
PortfolioRoleFactory, PortfolioRoleFactory,
@ -18,9 +16,9 @@ def test_expired_invite_is_not_revokable():
portfolio_role = PortfolioRoleFactory.create( portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
expiration_time=datetime.datetime.now() - datetime.timedelta(minutes=60), expiration_time=datetime.datetime.now() - datetime.timedelta(minutes=60),
portfolio_role=portfolio_role, role=portfolio_role,
) )
assert not invite.is_revokable assert not invite.is_revokable
@ -31,7 +29,7 @@ def test_unexpired_invite_is_revokable():
portfolio_role = PortfolioRoleFactory.create( portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create(portfolio_role=portfolio_role) invite = PortfolioInvitationFactory.create(role=portfolio_role)
assert invite.is_revokable assert invite.is_revokable
@ -41,7 +39,7 @@ def test_invite_is_not_revokable_if_invite_is_not_pending():
portfolio_role = PortfolioRoleFactory.create( portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
portfolio_role=portfolio_role, status=Status.ACCEPTED role=portfolio_role, status=InvitationStatus.ACCEPTED
) )
assert not invite.is_revokable assert not invite.is_revokable

View File

@ -6,14 +6,11 @@ from atst.domain.portfolios import Portfolios
from atst.domain.portfolio_roles import PortfolioRoles from atst.domain.portfolio_roles import PortfolioRoles
from atst.domain.applications import Applications from atst.domain.applications import Applications
from atst.domain.permission_sets import PermissionSets from atst.domain.permission_sets import PermissionSets
from atst.models.portfolio_role import Status from atst.models import AuditEvent, InvitationStatus, PortfolioRoleStatus, CSPRole
from atst.models.invitation import Status as InvitationStatus
from atst.models.audit_event import AuditEvent
from atst.models.portfolio_role import Status as PortfolioRoleStatus
from atst.models.environment_role import CSPRole
from tests.factories import ( from tests.factories import (
UserFactory, UserFactory,
InvitationFactory, PortfolioInvitationFactory,
PortfolioRoleFactory, PortfolioRoleFactory,
EnvironmentFactory, EnvironmentFactory,
EnvironmentRoleFactory, EnvironmentRoleFactory,
@ -189,12 +186,12 @@ def test_has_environment_roles():
def test_status_when_member_is_active(): def test_status_when_member_is_active():
portfolio_role = PortfolioRoleFactory.create(status=Status.ACTIVE) portfolio_role = PortfolioRoleFactory.create(status=PortfolioRoleStatus.ACTIVE)
assert portfolio_role.display_status == "Active" assert portfolio_role.display_status == "Active"
def test_status_when_member_is_disabled(): def test_status_when_member_is_disabled():
portfolio_role = PortfolioRoleFactory.create(status=Status.DISABLED) portfolio_role = PortfolioRoleFactory.create(status=PortfolioRoleStatus.DISABLED)
assert portfolio_role.display_status == "Disabled" assert portfolio_role.display_status == "Disabled"
@ -204,8 +201,8 @@ def test_status_when_invitation_has_been_rejected_for_expirations():
portfolio_role = PortfolioRoleFactory.create( portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
) )
invitation = InvitationFactory.create( PortfolioInvitationFactory.create(
portfolio_role=portfolio_role, status=InvitationStatus.REJECTED_EXPIRED role=portfolio_role, status=InvitationStatus.REJECTED_EXPIRED
) )
assert portfolio_role.display_status == "Invite expired" assert portfolio_role.display_status == "Invite expired"
@ -216,8 +213,8 @@ def test_status_when_invitation_has_been_rejected_for_wrong_user():
portfolio_role = PortfolioRoleFactory.create( portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
) )
invitation = InvitationFactory.create( PortfolioInvitationFactory.create(
portfolio_role=portfolio_role, status=InvitationStatus.REJECTED_WRONG_USER role=portfolio_role, status=InvitationStatus.REJECTED_WRONG_USER
) )
assert portfolio_role.display_status == "Error on invite" assert portfolio_role.display_status == "Error on invite"
@ -228,8 +225,8 @@ def test_status_when_invitation_has_been_revoked():
portfolio_role = PortfolioRoleFactory.create( portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
) )
invitation = InvitationFactory.create( PortfolioInvitationFactory.create(
portfolio_role=portfolio_role, status=InvitationStatus.REVOKED role=portfolio_role, status=InvitationStatus.REVOKED
) )
assert portfolio_role.display_status == "Invite revoked" assert portfolio_role.display_status == "Invite revoked"
@ -240,8 +237,8 @@ def test_status_when_invitation_is_expired():
portfolio_role = PortfolioRoleFactory.create( portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
) )
invitation = InvitationFactory.create( PortfolioInvitationFactory.create(
portfolio_role=portfolio_role, role=portfolio_role,
status=InvitationStatus.PENDING, status=InvitationStatus.PENDING,
expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1), expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1),
) )
@ -254,8 +251,8 @@ def test_can_not_resend_invitation_if_active():
portfolio_role = PortfolioRoleFactory.create( portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
) )
invitation = InvitationFactory.create( PortfolioInvitationFactory.create(
portfolio_role=portfolio_role, status=InvitationStatus.ACCEPTED role=portfolio_role, status=InvitationStatus.ACCEPTED
) )
assert not portfolio_role.can_resend_invitation assert not portfolio_role.can_resend_invitation
@ -266,8 +263,8 @@ def test_can_resend_invitation_if_expired():
portfolio_role = PortfolioRoleFactory.create( portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
) )
invitation = InvitationFactory.create( PortfolioInvitationFactory.create(
portfolio_role=portfolio_role, status=InvitationStatus.REJECTED_EXPIRED role=portfolio_role, status=InvitationStatus.REJECTED_EXPIRED
) )
assert portfolio_role.can_resend_invitation assert portfolio_role.can_resend_invitation

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

@ -6,12 +6,11 @@ from tests.factories import (
UserFactory, UserFactory,
PortfolioFactory, PortfolioFactory,
PortfolioRoleFactory, PortfolioRoleFactory,
InvitationFactory, PortfolioInvitationFactory,
TaskOrderFactory, TaskOrderFactory,
) )
from atst.domain.portfolios import Portfolios from atst.domain.portfolios import Portfolios
from atst.models.portfolio_role import Status as PortfolioRoleStatus from atst.models import InvitationStatus, PortfolioRoleStatus
from atst.models.invitation import Status as InvitationStatus
from atst.domain.users import Users from atst.domain.users import Users
from atst.domain.permission_sets import PermissionSets from atst.domain.permission_sets import PermissionSets
@ -22,7 +21,7 @@ def test_existing_member_accepts_valid_invite(client, user_session):
ws_role = PortfolioRoleFactory.create( ws_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create(user_id=user.id, portfolio_role=ws_role) invite = PortfolioInvitationFactory.create(user_id=user.id, role=ws_role)
# the user does not have access to the portfolio before accepting the invite # the user does not have access to the portfolio before accepting the invite
assert len(Portfolios.for_user(user)) == 0 assert len(Portfolios.for_user(user)) == 0
@ -50,17 +49,20 @@ def test_new_member_accepts_valid_invite(monkeypatch, client, user_session):
response = client.post( response = client.post(
url_for("portfolios.create_member", portfolio_id=portfolio.id), url_for("portfolios.create_member", portfolio_id=portfolio.id),
data={ data={
"perms_app_mgmt": PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT, "permission_sets-perms_app_mgmt": PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT,
"perms_funding": PermissionSets.VIEW_PORTFOLIO_FUNDING, "permission_sets-perms_funding": PermissionSets.VIEW_PORTFOLIO_FUNDING,
"perms_reporting": PermissionSets.VIEW_PORTFOLIO_REPORTS, "permission_sets-perms_reporting": PermissionSets.VIEW_PORTFOLIO_REPORTS,
"perms_portfolio_mgmt": PermissionSets.VIEW_PORTFOLIO_ADMIN, "permission_sets-perms_portfolio_mgmt": PermissionSets.VIEW_PORTFOLIO_ADMIN,
**user_info, "user_data-first_name": user_info["first_name"],
"user_data-last_name": user_info["last_name"],
"user_data-dod_id": user_info["dod_id"],
"user_data-email": user_info["email"],
}, },
) )
assert response.status_code == 302 assert response.status_code == 302
user = Users.get_by_dod_id(user_info["dod_id"]) user = Users.get_by_dod_id(user_info["dod_id"])
token = user.invitations[0].token token = user.portfolio_invitations[0].token
monkeypatch.setattr( monkeypatch.setattr(
"atst.domain.auth.should_redirect_to_user_profile", lambda *args: False "atst.domain.auth.should_redirect_to_user_profile", lambda *args: False
@ -84,10 +86,8 @@ def test_member_accepts_invalid_invite(client, user_session):
ws_role = PortfolioRoleFactory.create( ws_role = PortfolioRoleFactory.create(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
user_id=user.id, user_id=user.id, role=ws_role, status=InvitationStatus.REJECTED_WRONG_USER
portfolio_role=ws_role,
status=InvitationStatus.REJECTED_WRONG_USER,
) )
user_session(user) user_session(user)
response = client.get(url_for("portfolios.accept_invitation", token=invite.token)) response = client.get(url_for("portfolios.accept_invitation", token=invite.token))
@ -119,7 +119,7 @@ def test_user_accepts_invite_with_wrong_dod_id(client, user_session):
ws_role = PortfolioRoleFactory.create( ws_role = PortfolioRoleFactory.create(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create(user_id=user.id, portfolio_role=ws_role) invite = PortfolioInvitationFactory.create(user_id=user.id, role=ws_role)
user_session(different_user) user_session(different_user)
response = client.get(url_for("portfolios.accept_invitation", token=invite.token)) response = client.get(url_for("portfolios.accept_invitation", token=invite.token))
@ -132,9 +132,9 @@ def test_user_accepts_expired_invite(client, user_session):
ws_role = PortfolioRoleFactory.create( ws_role = PortfolioRoleFactory.create(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
user_id=user.id, user_id=user.id,
portfolio_role=ws_role, role=ws_role,
status=InvitationStatus.REJECTED_EXPIRED, status=InvitationStatus.REJECTED_EXPIRED,
expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1), expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1),
) )
@ -150,9 +150,9 @@ def test_revoke_invitation(client, user_session):
ws_role = PortfolioRoleFactory.create( ws_role = PortfolioRoleFactory.create(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
user_id=user.id, user_id=user.id,
portfolio_role=ws_role, role=ws_role,
status=InvitationStatus.REJECTED_EXPIRED, status=InvitationStatus.REJECTED_EXPIRED,
expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1), expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1),
) )
@ -176,9 +176,9 @@ def test_user_can_only_revoke_invites_in_their_portfolio(client, user_session):
portfolio_role = PortfolioRoleFactory.create( portfolio_role = PortfolioRoleFactory.create(
user=user, portfolio=other_portfolio, status=PortfolioRoleStatus.PENDING user=user, portfolio=other_portfolio, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
user_id=user.id, user_id=user.id,
portfolio_role=portfolio_role, role=portfolio_role,
status=InvitationStatus.REJECTED_EXPIRED, status=InvitationStatus.REJECTED_EXPIRED,
expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1), expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1),
) )
@ -202,9 +202,9 @@ def test_user_can_only_resend_invites_in_their_portfolio(client, user_session, q
portfolio_role = PortfolioRoleFactory.create( portfolio_role = PortfolioRoleFactory.create(
user=user, portfolio=other_portfolio, status=PortfolioRoleStatus.PENDING user=user, portfolio=other_portfolio, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
user_id=user.id, user_id=user.id,
portfolio_role=portfolio_role, role=portfolio_role,
status=InvitationStatus.REJECTED_EXPIRED, status=InvitationStatus.REJECTED_EXPIRED,
expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1), expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1),
) )
@ -227,8 +227,8 @@ def test_resend_invitation_sends_email(client, user_session, queue):
ws_role = PortfolioRoleFactory.create( ws_role = PortfolioRoleFactory.create(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
user_id=user.id, portfolio_role=ws_role, status=InvitationStatus.PENDING user_id=user.id, role=ws_role, status=InvitationStatus.PENDING
) )
user_session(portfolio.owner) user_session(portfolio.owner)
client.post( client.post(
@ -250,9 +250,9 @@ def test_existing_member_invite_resent_to_email_submitted_in_form(
ws_role = PortfolioRoleFactory.create( ws_role = PortfolioRoleFactory.create(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
) )
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
user_id=user.id, user_id=user.id,
portfolio_role=ws_role, role=ws_role,
status=InvitationStatus.PENDING, status=InvitationStatus.PENDING,
email="example@example.com", email="example@example.com",
) )
@ -290,7 +290,7 @@ def test_contracting_officer_accepts_invite(monkeypatch, client, user_session):
# contracting officer accepts invitation # contracting officer accepts invitation
user = Users.get_by_dod_id(user_info["dod_id"]) user = Users.get_by_dod_id(user_info["dod_id"])
token = user.invitations[0].token token = user.portfolio_invitations[0].token
monkeypatch.setattr( monkeypatch.setattr(
"atst.domain.auth.should_redirect_to_user_profile", lambda *args: False "atst.domain.auth.should_redirect_to_user_profile", lambda *args: False
) )
@ -324,7 +324,7 @@ def test_cor_accepts_invite(monkeypatch, client, user_session):
# contracting officer representative accepts invitation # contracting officer representative accepts invitation
user = Users.get_by_dod_id(user_info["dod_id"]) user = Users.get_by_dod_id(user_info["dod_id"])
token = user.invitations[0].token token = user.portfolio_invitations[0].token
monkeypatch.setattr( monkeypatch.setattr(
"atst.domain.auth.should_redirect_to_user_profile", lambda *args: False "atst.domain.auth.should_redirect_to_user_profile", lambda *args: False
) )
@ -358,7 +358,7 @@ def test_so_accepts_invite(monkeypatch, client, user_session):
# security officer accepts invitation # security officer accepts invitation
user = Users.get_by_dod_id(user_info["dod_id"]) user = Users.get_by_dod_id(user_info["dod_id"])
token = user.invitations[0].token token = user.portfolio_invitations[0].token
monkeypatch.setattr( monkeypatch.setattr(
"atst.domain.auth.should_redirect_to_user_profile", lambda *args: False "atst.domain.auth.should_redirect_to_user_profile", lambda *args: False
) )

View File

@ -5,10 +5,10 @@ from atst.domain.permission_sets import PermissionSets
from atst.queue import queue from atst.queue import queue
_DEFAULT_PERMS_FORM_DATA = { _DEFAULT_PERMS_FORM_DATA = {
"perms_app_mgmt": PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT, "permission_sets-perms_app_mgmt": PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT,
"perms_funding": PermissionSets.VIEW_PORTFOLIO_FUNDING, "permission_sets-perms_funding": PermissionSets.VIEW_PORTFOLIO_FUNDING,
"perms_reporting": PermissionSets.VIEW_PORTFOLIO_REPORTS, "permission_sets-perms_reporting": PermissionSets.VIEW_PORTFOLIO_REPORTS,
"perms_portfolio_mgmt": PermissionSets.VIEW_PORTFOLIO_ADMIN, "permission_sets-perms_portfolio_mgmt": PermissionSets.VIEW_PORTFOLIO_ADMIN,
} }
@ -32,11 +32,11 @@ def test_create_member(client, user_session):
response = client.post( response = client.post(
url_for("portfolios.create_member", portfolio_id=portfolio.id), url_for("portfolios.create_member", portfolio_id=portfolio.id),
data={ data={
"dod_id": user.dod_id, "user_data-dod_id": user.dod_id,
"first_name": "Wilbur", "user_data-first_name": "user_data-Wilbur",
"last_name": "Zuckerman", "user_data-last_name": "user_data-Zuckerman",
"email": "some_pig@zuckermans.com", "user_data-email": "user_data-some_pig@zuckermans.com",
"portfolio_role": "developer", "user_data-portfolio_role": "user_data-developer",
**_DEFAULT_PERMS_FORM_DATA, **_DEFAULT_PERMS_FORM_DATA,
}, },
follow_redirects=True, follow_redirects=True,
@ -45,7 +45,7 @@ def test_create_member(client, user_session):
assert response.status_code == 200 assert response.status_code == 200
assert user.full_name in response.data.decode() assert user.full_name in response.data.decode()
assert user.has_portfolios assert user.has_portfolios
assert user.invitations assert user.portfolio_invitations
assert len(queue.get_queue()) == queue_length + 1 assert len(queue.get_queue()) == queue_length + 1
portfolio_role = user.portfolio_roles[0] portfolio_role = user.portfolio_roles[0]
assert len(portfolio_role.permission_sets) == 5 assert len(portfolio_role.permission_sets) == 5

View File

@ -4,7 +4,7 @@ from flask import url_for
import pytest import pytest
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.models.invitation import Status as InvitationStatus from atst.models import InvitationStatus
from atst.models.portfolio_role import Status as PortfolioStatus from atst.models.portfolio_role import Status as PortfolioStatus
from atst.queue import queue from atst.queue import queue
@ -13,7 +13,7 @@ from tests.factories import (
TaskOrderFactory, TaskOrderFactory,
UserFactory, UserFactory,
PortfolioRoleFactory, PortfolioRoleFactory,
InvitationFactory, PortfolioInvitationFactory,
) )
@ -79,7 +79,7 @@ def test_does_not_resend_officer_invitation(client, user_session):
user_session(user) user_session(user)
for i in range(2): for i in range(2):
client.post(url_for("task_orders.invite", task_order_id=task_order.id)) client.post(url_for("task_orders.invite", task_order_id=task_order.id))
assert len(contracting_officer.invitations) == 1 assert len(contracting_officer.portfolio_invitations) == 1
def test_does_not_invite_if_task_order_incomplete(client, user_session, queue): def test_does_not_invite_if_task_order_incomplete(client, user_session, queue):
@ -272,9 +272,9 @@ class TestTaskOrderInvitations:
cor_invite=True, cor_invite=True,
) )
portfolio_role = PortfolioRoleFactory.create(portfolio=self.portfolio, user=cor) portfolio_role = PortfolioRoleFactory.create(portfolio=self.portfolio, user=cor)
invitation = InvitationFactory.create( PortfolioInvitationFactory.create(
inviter=self.portfolio.owner, inviter=self.portfolio.owner,
portfolio_role=portfolio_role, role=portfolio_role,
user=cor, user=cor,
status=InvitationStatus.PENDING, status=InvitationStatus.PENDING,
) )
@ -369,9 +369,9 @@ def test_resend_invite_when_not_pending(app, client, user_session, portfolio, us
portfolio=portfolio, user=user, status=PortfolioStatus.ACTIVE portfolio=portfolio, user=user, status=PortfolioStatus.ACTIVE
) )
original_invitation = InvitationFactory.create( original_invitation = PortfolioInvitationFactory.create(
inviter=user, inviter=user,
portfolio_role=portfolio_role, role=portfolio_role,
email=user.email, email=user.email,
status=InvitationStatus.ACCEPTED, status=InvitationStatus.ACCEPTED,
) )
@ -397,9 +397,9 @@ def test_resending_revoked_invite(app, client, user_session, portfolio, user):
portfolio_role = PortfolioRoleFactory.create(portfolio=portfolio, user=user) portfolio_role = PortfolioRoleFactory.create(portfolio=portfolio, user=user)
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
inviter=user, inviter=user,
portfolio_role=portfolio_role, role=portfolio_role,
email=user.email, email=user.email,
status=InvitationStatus.REVOKED, status=InvitationStatus.REVOKED,
) )
@ -427,9 +427,9 @@ def test_resending_expired_invite(app, client, user_session, portfolio):
portfolio=portfolio, contracting_officer=ko, ko_invite=True portfolio=portfolio, contracting_officer=ko, ko_invite=True
) )
portfolio_role = PortfolioRoleFactory.create(portfolio=portfolio, user=ko) portfolio_role = PortfolioRoleFactory.create(portfolio=portfolio, user=ko)
invite = InvitationFactory.create( invite = PortfolioInvitationFactory.create(
inviter=portfolio.owner, inviter=portfolio.owner,
portfolio_role=portfolio_role, role=portfolio_role,
email=ko.email, email=ko.email,
expiration_time=datetime.now() - timedelta(days=1), expiration_time=datetime.now() - timedelta(days=1),
) )

View File

@ -1,14 +1,31 @@
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)
ws_member = PortfolioRoleFactory.create(user=new_member, portfolio=portfolio) ws_member = PortfolioRoleFactory.create(user=new_member, portfolio=portfolio)
invite_service = Invitation(inviter, ws_member, new_member.email) invite_service = Invitation(inviter, ws_member, new_member.email)
new_invitation = invite_service.invite() new_invitation = invite_service.invite()
assert new_invitation == new_member.invitations[0] 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 assert len(queue.get_queue()) == 1

View File

@ -17,7 +17,7 @@ from tests.factories import (
ApplicationRoleFactory, ApplicationRoleFactory,
EnvironmentFactory, EnvironmentFactory,
EnvironmentRoleFactory, EnvironmentRoleFactory,
InvitationFactory, PortfolioInvitationFactory,
PortfolioFactory, PortfolioFactory,
PortfolioRoleFactory, PortfolioRoleFactory,
TaskOrderFactory, TaskOrderFactory,
@ -75,7 +75,9 @@ def test_all_protected_routes_have_access_control(
monkeypatch.setattr("atst.domain.portfolios.Portfolios.get", lambda *a: None) monkeypatch.setattr("atst.domain.portfolios.Portfolios.get", lambda *a: None)
monkeypatch.setattr("atst.domain.task_orders.TaskOrders.get", lambda *a: Mock()) monkeypatch.setattr("atst.domain.task_orders.TaskOrders.get", lambda *a: Mock())
monkeypatch.setattr("atst.domain.applications.Applications.get", lambda *a: Mock()) monkeypatch.setattr("atst.domain.applications.Applications.get", lambda *a: Mock())
monkeypatch.setattr("atst.domain.invitations.Invitations._get", lambda *a: Mock()) monkeypatch.setattr(
"atst.domain.invitations.PortfolioInvitations._get", lambda *a: Mock()
)
monkeypatch.setattr( monkeypatch.setattr(
"atst.utils.context_processors.get_portfolio_from_context", lambda *a: None "atst.utils.context_processors.get_portfolio_from_context", lambda *a: None
) )
@ -402,7 +404,7 @@ def test_portfolios_resend_invitation_access(post_url_assert_status):
portfolio = PortfolioFactory.create(owner=owner) portfolio = PortfolioFactory.create(owner=owner)
prr = PortfolioRoleFactory.create(user=invitee, portfolio=portfolio) prr = PortfolioRoleFactory.create(user=invitee, portfolio=portfolio)
invite = InvitationFactory.create(user=UserFactory.create(), portfolio_role=prr) invite = PortfolioInvitationFactory.create(user=UserFactory.create(), role=prr)
url = url_for( url = url_for(
"portfolios.resend_invitation", portfolio_id=portfolio.id, token=invite.token "portfolios.resend_invitation", portfolio_id=portfolio.id, token=invite.token
@ -423,7 +425,7 @@ def test_task_orders_resend_invite_access(post_url_assert_status):
portfolio = PortfolioFactory.create(owner=owner) portfolio = PortfolioFactory.create(owner=owner)
task_order = TaskOrderFactory.create(portfolio=portfolio, contracting_officer=ko) task_order = TaskOrderFactory.create(portfolio=portfolio, contracting_officer=ko)
prr = PortfolioRoleFactory.create(user=ko, portfolio=portfolio) prr = PortfolioRoleFactory.create(user=ko, portfolio=portfolio)
invite = InvitationFactory.create(user=UserFactory.create(), portfolio_role=prr) PortfolioInvitationFactory.create(user=UserFactory.create(), role=prr)
url = url_for( url = url_for(
"task_orders.resend_invite", "task_orders.resend_invite",
@ -449,7 +451,7 @@ def test_portfolios_revoke_invitation_access(post_url_assert_status):
prr = PortfolioRoleFactory.create( prr = PortfolioRoleFactory.create(
user=prt_member, portfolio=portfolio, status=PortfolioRoleStatus.ACTIVE user=prt_member, portfolio=portfolio, status=PortfolioRoleStatus.ACTIVE
) )
invite = InvitationFactory.create(user=prt_member, portfolio_role=prr) invite = PortfolioInvitationFactory.create(user=prt_member, role=prr)
url = url_for( url = url_for(
"portfolios.revoke_invitation", "portfolios.revoke_invitation",
portfolio_id=portfolio.id, portfolio_id=portfolio.id,

View File

@ -35,6 +35,9 @@ common:
save_and_continue: Save & continue save_and_continue: Save & continue
show: Show show: Show
sign: Sign sign: Sign
resource_names:
environments: Environments
choose_role: Choose a role
components: components:
modal: modal:
close: Close close: Close
@ -65,6 +68,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
@ -433,6 +437,14 @@ portfolios:
user: User user: User
team_text: Team team_text: Team
update_button_text: Save update_button_text: Save
members:
new:
assign_roles: Assign Member Environments and Roles
learn_more: Learn more about these roles
manage_perms: 'Manage permissions for {application_name}'
manage_envs: 'Allow member to <strong>add</strong> and <strong>rename environments</strong> within the application.'
delete_envs: 'Allow member to <strong>delete environments</strong> within the application.'
manage_team: 'Allow member to <strong>add, update,</strong> and <strong>remove members</strong> from the application team.'
index: index:
empty: empty:
start_button: Start a new JEDI portfolio start_button: Start a new JEDI portfolio