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

View File

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

View File

@ -2,8 +2,9 @@ import datetime
from sqlalchemy.orm.exc import NoResultFound
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.application_roles import ApplicationRoles
from .exceptions import NotFoundError
@ -38,27 +39,30 @@ class InvitationError(Exception):
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
EXPIRATION_LIMIT_MINUTES = 360
@classmethod
def _get(cls, token):
try:
invite = db.session.query(Invitation).filter_by(token=token).one()
invite = db.session.query(cls.model).filter_by(token=token).one()
except NoResultFound:
raise NotFoundError("invite")
raise NotFoundError(cls.model.__tablename__)
return invite
@classmethod
def create(cls, inviter, portfolio_role, email):
invite = Invitation(
portfolio_role=portfolio_role,
def create(cls, inviter, role, email):
# pylint: disable=not-callable
invite = cls.model(
role=role,
inviter=inviter,
user=portfolio_role.user,
user=role.user,
status=InvitationStatus.PENDING,
expiration_time=Invitations.current_expiration_time(),
expiration_time=cls.current_expiration_time(),
email=email,
)
db.session.add(invite)
@ -68,29 +72,29 @@ class Invitations(object):
@classmethod
def accept(cls, user, token):
invite = Invitations._get(token)
invite = cls._get(token)
if invite.user.dod_id != user.dod_id:
if invite.is_pending:
Invitations._update_status(invite, InvitationStatus.REJECTED_WRONG_USER)
cls._update_status(invite, InvitationStatus.REJECTED_WRONG_USER)
raise WrongUserError(user, invite)
elif invite.is_expired:
Invitations._update_status(invite, InvitationStatus.REJECTED_EXPIRED)
cls._update_status(invite, InvitationStatus.REJECTED_EXPIRED)
raise ExpiredError(invite)
elif invite.is_accepted or invite.is_revoked or invite.is_rejected:
raise InvitationError(invite)
elif invite.is_pending: # pragma: no branch
Invitations._update_status(invite, InvitationStatus.ACCEPTED)
PortfolioRoles.enable(invite.portfolio_role)
cls._update_status(invite, InvitationStatus.ACCEPTED)
cls.role_domain_class.enable(invite.role)
return invite
@classmethod
def current_expiration_time(cls):
return datetime.datetime.now() + datetime.timedelta(
minutes=Invitations.EXPIRATION_LIMIT_MINUTES
minutes=cls.EXPIRATION_LIMIT_MINUTES
)
@classmethod
@ -103,23 +107,31 @@ class Invitations(object):
@classmethod
def revoke(cls, token):
invite = Invitations._get(token)
return Invitations._update_status(invite, InvitationStatus.REVOKED)
invite = cls._get(token)
return cls._update_status(invite, InvitationStatus.REVOKED)
@classmethod
def lookup_by_portfolio_and_user(cls, portfolio, user):
portfolio_role = PortfolioRoles.get(portfolio.id, user.id)
def lookup_by_resource_and_user(cls, resource, user):
role = cls.role_domain_class.get(resource.id, user.id)
if portfolio_role.latest_invitation is None:
raise NotFoundError("invitation")
if role.latest_invitation is None:
raise NotFoundError(cls.model.__tablename__)
return portfolio_role.latest_invitation
return role.latest_invitation
@classmethod
def resend(cls, user, token):
previous_invitation = Invitations._get(token)
Invitations._update_status(previous_invitation, InvitationStatus.REVOKED)
previous_invitation = cls._get(token)
cls._update_status(previous_invitation, InvitationStatus.REVOKED)
return Invitations.create(
user, previous_invitation.portfolio_role, previous_invitation.email
)
return cls.create(user, previous_invitation.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.models.environment_role import CSPRole
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, Email, Length, Optional
from wtforms.validators import Required
from wtforms.fields import StringField, FormField, FieldList, HiddenField
from atst.domain.permission_sets import PermissionSets
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.utils.localization import translate
@ -62,24 +61,16 @@ class EditForm(PermissionsForm):
pass
class NewForm(PermissionsForm):
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()],
)
class NewForm(BaseForm):
user_data = FormField(BaseNewMemberForm)
permission_sets = FormField(PermissionsForm)
@property
def update_data(self):
return {
"permission_sets": self.data.get("permission_sets").get("permission_sets"),
**self.data.get("user_data"),
}
class AssignPPOCForm(PermissionsForm):

View File

@ -5,14 +5,17 @@ Base = declarative_base()
from .permissions import Permissions
from .permission_set import PermissionSet
from .user import User
from .portfolio_role import PortfolioRole
from .application_role import ApplicationRole
from .environment_role import EnvironmentRole
from .portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
from .application_role import ApplicationRole, Status as ApplicationRoleStatus
from .environment_role import EnvironmentRole, CSPRole
from .portfolio import Portfolio
from .application import Application
from .environment import Environment
from .attachment import Attachment
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 .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
)
@property
def user_name(self):
return self.user.full_name
def __repr__(self):
return "<ApplicationRole(application='{}', user_id='{}', id='{}', permissions={})>".format(
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 .permissions import PermissionsMixin
from .deletable import DeletableMixin
from .invites import InvitesMixin

View File

@ -3,12 +3,11 @@ from enum import Enum
import secrets
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.orm import relationship, backref
from sqlalchemy.orm import relationship
from atst.models import Base, types
from atst.models.mixins.timestamps import TimestampsMixin
from atst.models.mixins.auditable import AuditableMixin
from atst.models import types
class Status(Enum):
@ -19,24 +18,24 @@ class Status(Enum):
REJECTED_EXPIRED = "rejected_expired"
class Invitation(Base, TimestampsMixin, AuditableMixin):
__tablename__ = "invitations"
class InvitesMixin(object):
id = types.Id()
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
user = relationship("User", backref="invitations", foreign_keys=[user_id])
@declared_attr
def user_id(cls):
return Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
portfolio_role_id = Column(
UUID(as_uuid=True), ForeignKey("portfolio_roles.id"), index=True
)
portfolio_role = relationship(
"PortfolioRole",
backref=backref("invitations", order_by="Invitation.time_created"),
)
@declared_attr
def user(cls):
return relationship("User", foreign_keys=[cls.user_id])
inviter_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
inviter = relationship("User", backref="sent_invites", foreign_keys=[inviter_id])
@declared_attr
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))
@ -47,8 +46,9 @@ class Invitation(Base, TimestampsMixin, AuditableMixin):
email = Column(String, nullable=False)
def __repr__(self):
return "<Invitation(user='{}', portfolio_role='{}', id='{}', email='{}')>".format(
self.user_id, self.portfolio_role_id, self.id, self.email
role_id = self.role.id if self.role else None
return "<{}(user='{}', role='{}', id='{}', email='{}')>".format(
self.__class__.__name__, self.user_id, role_id, self.id, self.email
)
@property
@ -90,14 +90,9 @@ class Invitation(Base, TimestampsMixin, AuditableMixin):
Status.REVOKED,
]
@property
def portfolio(self):
if self.portfolio_role: # pragma: no branch
return self.portfolio_role.portfolio
@property
def user_name(self):
return self.portfolio_role.user.full_name
return self.role.user.full_name
@property
def is_revokable(self):
@ -110,21 +105,3 @@ class Invitation(Base, TimestampsMixin, AuditableMixin):
@property
def user_dod_id(self):
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.permissions import Permissions
from atst.models.portfolio_invitation import PortfolioInvitation
from atst.models.application_invitation import ApplicationInvitation
users_permission_sets = Table(
@ -31,6 +33,20 @@ class User(
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)
dod_id = Column(String, unique=True, nullable=False)
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 atst.domain.environments import Environments
from atst.domain.applications import Applications
from atst.domain.authz.decorator import user_can_access_decorator as user_can
from atst.models.permissions import Permissions
from atst.domain.permission_sets import PermissionSets
from atst.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
@ -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(
"portfolios/applications/team.html",
application=application,
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 . import portfolios_bp
from atst.domain.invitations import Invitations
from atst.domain.invitations import PortfolioInvitations
from atst.queue import queue
from atst.utils.flash import formatted_flash as flash
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):
body = render_template("emails/invitation.txt", owner=owner_name, token=token)
body = render_template(
"emails/portfolio/invitation.txt", owner=owner_name, token=token
)
queue.send_mail(
[new_member_email],
"{} has invited you to a JEDI Cloud Portfolio".format(owner_name),
@ -19,7 +21,7 @@ def send_invite_email(owner_name, token, new_member_email):
@portfolios_bp.route("/portfolios/invitations/<token>", methods=["GET"])
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:
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")
def revoke_invitation(portfolio_id, token):
Invitations.revoke(token)
PortfolioInvitations.revoke(token)
return redirect(
url_for(
@ -54,7 +56,7 @@ def revoke_invitation(portfolio_id, token):
)
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="resend invitation")
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)
flash("resend_portfolio_invitation", user_name=invite.user_name)
return redirect(

View File

@ -34,9 +34,9 @@ def create_member(portfolio_id):
if form.validate():
try:
member = Portfolios.create_member(portfolio, form.data)
member = Portfolios.create_member(portfolio, form.update_data)
invite_service = InvitationService(
g.current_user, member, form.data.get("email")
g.current_user, member, form.update_data.get("email")
)
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.database import db
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.utils.localization import translate
from atst.forms.officers import EditTaskOrderOfficersForm
@ -57,7 +57,7 @@ def resend_invite(task_order_id):
if not 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:
raise NotFoundError("invitation")
@ -65,11 +65,11 @@ def resend_invite(task_order_id):
if not invitation.can_resend:
raise NoAccessError("invitation")
Invitations.revoke(token=invitation.token)
PortfolioInvitations.revoke(token=invitation.token)
invite_service = InvitationService(
g.current_user,
invitation.portfolio_role,
invitation.role,
invitation.email,
subject=invite_type_info["subject"],
email_template=invite_type_info["template"],

View File

@ -1,25 +1,26 @@
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.domain.task_orders import TaskOrders
from atst.domain.portfolio_roles import PortfolioRoles
from atst.models import ApplicationRole, PortfolioRole
OFFICER_INVITATIONS = {
"ko_invite": {
"role": "contracting_officer",
"subject": "Review a task order",
"template": "emails/invitation.txt",
"template": "emails/portfolio/invitation.txt",
},
"cor_invite": {
"role": "contracting_officer_representative",
"subject": "Help with a task order",
"template": "emails/invitation.txt",
"template": "emails/portfolio/invitation.txt",
},
"so_invite": {
"role": "security_officer",
"subject": "Review security for a task order",
"template": "emails/invitation.txt",
"template": "emails/portfolio/invitation.txt",
},
}
@ -47,20 +48,30 @@ def update_officer_invitations(user, task_order):
class Invitation:
def __init__(
self,
inviter,
member,
email,
subject="{} has invited you to a JEDI cloud portfolio",
email_template="emails/invitation.txt",
):
def __init__(self, inviter, member, email, subject="", email_template=None):
self.inviter = inviter
self.member = member
self.email = email
self.subject = subject
self.email_template = email_template
if isinstance(member, PortfolioRole):
self.email_template = (
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):
invite = self._create_invite()
self._send_invite_email(invite.token)
@ -68,7 +79,7 @@ class Invitation:
return invite
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):
body = render_template(

View File

@ -161,6 +161,13 @@ MESSAGES = {
""",
"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 nestedcheckboxinput from './nested_checkbox_input'
export default {
name: 'checkboxinput',
components: {
nestedcheckboxinput,
},
props: {
name: String,
initialChecked: Boolean,
},
data: function() {
return {
isChecked: this.initialChecked,
}
},
methods: {

View File

@ -1,6 +1,7 @@
import FormMixin from '../../mixins/form'
import textinput from '../text_input'
import optionsinput from '../options_input'
import checkboxinput from '../checkbox_input'
import Selector from '../selector'
import Modal from '../../mixins/modal'
import toggler from '../toggler'
@ -16,6 +17,7 @@ export default {
Selector,
textinput,
optionsinput,
checkboxinput,
},
props: {
@ -45,6 +47,9 @@ export default {
this.step += 1
}
},
previous: function() {
this.step -= 1
},
goToStep: function(step) {
if (this._checkIsValid()) {
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,
showValid: !showError && !!this.initialValue,
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 textinput from './components/text_input'
import checkboxinput from './components/checkbox_input'
import nestedcheckboxinput from './components/nested_checkbox_input'
import EditOfficerForm from './components/forms/edit_officer_form'
import poc from './components/forms/poc'
import oversight from './components/forms/oversight'
@ -74,6 +75,7 @@ const app = new Vue({
KoReview,
BaseForm,
DeleteConfirmation,
nestedcheckboxinput,
},
mounted: function() {

View File

@ -35,7 +35,7 @@ export default {
},
email: {
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: [],
validationError: 'Please enter a valid e-mail address',
},

View File

@ -46,6 +46,14 @@
flex-basis: 16.66%;
}
&.form-col--quarter {
flex-basis: 25%;
}
&.form-col--three-quarters {
flex-basis: 75%;
}
.usa-input {
margin-left: ($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;
padding: $gap * 2;
width: 100%;
border-radius: 5px;
overflow-y: auto;
-ms-overflow-style: scrollbar;
@ -52,7 +53,7 @@ body {
}
@include media($medium-screen) {
padding: $gap * 4;
padding: $gap * 5;
}
h1, h2 {
@ -186,6 +187,7 @@ body {
.form-row {
margin-top: 0;
margin-bottom: 0;
.form-col {
.usa-input {

View File

@ -21,4 +21,9 @@
&:last-child {
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,23 +1,31 @@
{% macro CheckboxInput(
field,
label=field.label | striptags,
label=field.label,
inline=False,
classes="") -%}
<checkboxinput name='{{ field.name }}' inline-template key='{{ field.name }}'>
<div class='usa-input {{ classes }} {% if field.errors %}usa-input--error{% endif %}'>
<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 %}'>
<fieldset data-ally-disabled="true" v-on:change="onInput" class="usa-input__choices {% if inline %}usa-input__choices--inline{% endif %}">
<legend>
{{ field() }}
<label for={{field.name}}>
{{ label }}
</label>
<fieldset data-ally-disabled="true" v-on:change="onInput" class="usa-input__choices {% if inline %}usa-input__choices--inline{% endif %}">
<legend>
{{ field(**{"v-model": "isChecked"}) }}
{{ label | safe }}
{% if field.description %}
{% if field.description %}
<span class='usa-input__help'>{{ field.description | safe }}</span>
{% endif %}
</legend>
</fieldset>
{% endif %}
</legend>
</fieldset>
</div>
{% if caller %}
{{ caller() }}
{% endif %}
</div>
</checkboxinput>
{%- 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
{{ owner }} has invited you to join a JEDI Cloud Portfolio. Login now to view or use your JEDI Cloud resources.
{{ url_for("portfolios.accept_invitation", token=token, _external=True) }}
{% block content %}{% endblock %}
What is JEDI Cloud?
JEDI Cloud is a DoD enterprise-wide solution for commercial cloud services.

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 class='form-row'>
<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 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 class='form-row'>
<div class='form-col form-col--half'>
{{ TextInput(member_form.email, validation='email') }}
{{ TextInput(member_form.user_data.email, validation='email') }}
</div>
<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 class='form-row'>
<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 class='form-col form-col--half'>
</div>
@ -61,10 +61,10 @@
{{ "portfolios.admin.permissions_info" | translate }}
</a>
</div>
{{ SimpleOptionsInput(member_form.perms_app_mgmt) }}
{{ SimpleOptionsInput(member_form.perms_funding) }}
{{ SimpleOptionsInput(member_form.perms_reporting) }}
{{ SimpleOptionsInput(member_form.perms_portfolio_mgmt) }}
{{ SimpleOptionsInput(member_form.permission_sets.perms_app_mgmt) }}
{{ SimpleOptionsInput(member_form.permission_sets.perms_funding) }}
{{ SimpleOptionsInput(member_form.permission_sets.perms_reporting) }}
{{ SimpleOptionsInput(member_form.permission_sets.perms_portfolio_mgmt) }}
<div class='action-group'>
<input
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,93 +28,95 @@
{% if g.matchesPath("application-members") %}
{% include "fragments/flash.html" %}
{% endif %}
<form>
<header>
<div class="responsive-table-wrapper__header">
<div class="responsive-table-wrapper__title">
<div class="h3">
{{ "portfolios.applications.team_settings.section.title" | translate({ "application_name": application.name }) }}
</div>
<header>
<div class="responsive-table-wrapper__header">
<div class="responsive-table-wrapper__title">
<div class="h3">
{{ "portfolios.applications.team_settings.section.title" | translate({ "application_name": application.name }) }}
</div>
<a class='icon-link'>
{{ Icon('info') }}
{{ "portfolios.admin.settings_info" | translate }}
</a>
</div>
</header>
<div class="accordion-table accordion-table-list">
<div class="accordion-table__head">
<span>
<span>
{{ "portfolios.applications.team_settings.user" | translate }}
</span>
<span>
{{ "portfolios.applications.team_settings.section.table.delete_access" | translate }}
</span>
<span>
{{ "portfolios.applications.team_settings.section.table.environment_management" | translate }}
</span>
<span>
{{ "portfolios.applications.team_settings.section.table.team_management" | translate }}
</span>
</span>
<span class="icon-link" />
</div>
<ul class="accordion-table__items">
{% for member in application.members %}
{% set user = member.user %}
{% set user_info = environment_users[user.id] %}
{% set user_permissions = user_info["permissions"] %}
<toggler inline-template>
<li class="accordion-table__item">
<div class="accordion-table__item-content">
<span>
{{ name }}
<span>{{ user.full_name }}</span>
<span>{{ user_permissions["delete_access"] }}</span>
<span>{{ user_permissions["environment_management"] }}</span>
<span>{{ user_permissions["team_management"] }}</span>
</span>
<span class="icon-link icon-link--large accordion-table__item__toggler">
{% set open_html %}
{{ "common.show" | translate }} {{ "portfolios.applications.team_settings.environments" | translate }} ({{ user_info['environments'] | length }})
{% endset %}
{% set close_html %}
{{ "common.hide" | translate }} {{ "portfolios.applications.team_settings.environments" | translate }} ({{ user_info['environments'] | length }})
{% endset %}
{{
ToggleButton(
open_html=open_html,
close_html=close_html,
section_name="environments"
)
}}
</span>
</div>
{% call ToggleSection(section_name="environments") %}
<ul>
{% for environment in user_info["environments"] %}
<li>
<div class="accordion-table__item-content">
{{ environment.name }}
</div>
</li>
{% endfor %}
</ul>
{% endcall %}
</li>
</toggler>
{% endfor %}
</ul>
<a class='icon-link'>
{{ Icon('info') }}
{{ "portfolios.admin.settings_info" | translate }}
</a>
</div>
</header>
<div class="accordion-table accordion-table-list">
<div class="accordion-table__head">
<span>
<span>
{{ "portfolios.applications.team_settings.user" | translate }}
</span>
<span>
{{ "portfolios.applications.team_settings.section.table.delete_access" | translate }}
</span>
<span>
{{ "portfolios.applications.team_settings.section.table.environment_management" | translate }}
</span>
<span>
{{ "portfolios.applications.team_settings.section.table.team_management" | translate }}
</span>
</span>
<span class="icon-link" />
</div>
<ul class="accordion-table__items">
{% for member in application.members %}
{% set user = member.user %}
{% set user_info = environment_users[user.id] %}
{% set user_permissions = user_info["permissions"] %}
<toggler inline-template>
<li class="accordion-table__item">
<div class="accordion-table__item-content">
<span>
{{ name }}
<span>{{ user.full_name }}</span>
<span>{{ user_permissions["delete_access"] }}</span>
<span>{{ user_permissions["environment_management"] }}</span>
<span>{{ user_permissions["team_management"] }}</span>
</span>
<span class="icon-link icon-link--large accordion-table__item__toggler">
{% set open_html %}
{{ "common.show" | translate }} {{ "portfolios.applications.team_settings.environments" | translate }} ({{ user_info['environments'] | length }})
{% endset %}
{% set close_html %}
{{ "common.hide" | translate }} {{ "portfolios.applications.team_settings.environments" | translate }} ({{ user_info['environments'] | length }})
{% endset %}
{{
ToggleButton(
open_html=open_html,
close_html=close_html,
section_name="environments"
)
}}
</span>
</div>
{% call ToggleSection(section_name="environments") %}
<ul>
{% for environment in user_info["environments"] %}
<li>
<div class="accordion-table__item-content">
{{ environment.name }}
</div>
</li>
{% endfor %}
</ul>
{% endcall %}
</li>
</toggler>
{% endfor %}
</ul>
</div>
<div class="members-table-footer">
<div class="action-group save">
{% if user_can(permissions.CREATE_APPLICATION_MEMBER) %}
{% include "fragments/applications/add_new_application_member.html" %}
{% endif %}
</div>
</div>
</form>

View File

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

View File

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

View File

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

View File

@ -1,11 +1,9 @@
import pytest
import datetime
from atst.models.invitation import Invitation, Status
from atst.models.portfolio_role import Status as PortfolioRoleStatus
from atst.models import InvitationStatus, PortfolioRoleStatus
from tests.factories import (
InvitationFactory,
PortfolioInvitationFactory,
PortfolioFactory,
UserFactory,
PortfolioRoleFactory,
@ -18,9 +16,9 @@ def test_expired_invite_is_not_revokable():
portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
)
invite = InvitationFactory.create(
invite = PortfolioInvitationFactory.create(
expiration_time=datetime.datetime.now() - datetime.timedelta(minutes=60),
portfolio_role=portfolio_role,
role=portfolio_role,
)
assert not invite.is_revokable
@ -31,7 +29,7 @@ def test_unexpired_invite_is_revokable():
portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
)
invite = InvitationFactory.create(portfolio_role=portfolio_role)
invite = PortfolioInvitationFactory.create(role=portfolio_role)
assert invite.is_revokable
@ -41,7 +39,7 @@ def test_invite_is_not_revokable_if_invite_is_not_pending():
portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
)
invite = InvitationFactory.create(
portfolio_role=portfolio_role, status=Status.ACCEPTED
invite = PortfolioInvitationFactory.create(
role=portfolio_role, status=InvitationStatus.ACCEPTED
)
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.applications import Applications
from atst.domain.permission_sets import PermissionSets
from atst.models.portfolio_role import Status
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 atst.models import AuditEvent, InvitationStatus, PortfolioRoleStatus, CSPRole
from tests.factories import (
UserFactory,
InvitationFactory,
PortfolioInvitationFactory,
PortfolioRoleFactory,
EnvironmentFactory,
EnvironmentRoleFactory,
@ -189,12 +186,12 @@ def test_has_environment_roles():
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"
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"
@ -204,8 +201,8 @@ def test_status_when_invitation_has_been_rejected_for_expirations():
portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
)
invitation = InvitationFactory.create(
portfolio_role=portfolio_role, status=InvitationStatus.REJECTED_EXPIRED
PortfolioInvitationFactory.create(
role=portfolio_role, status=InvitationStatus.REJECTED_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=portfolio, user=user, status=PortfolioRoleStatus.PENDING
)
invitation = InvitationFactory.create(
portfolio_role=portfolio_role, status=InvitationStatus.REJECTED_WRONG_USER
PortfolioInvitationFactory.create(
role=portfolio_role, status=InvitationStatus.REJECTED_WRONG_USER
)
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=portfolio, user=user, status=PortfolioRoleStatus.PENDING
)
invitation = InvitationFactory.create(
portfolio_role=portfolio_role, status=InvitationStatus.REVOKED
PortfolioInvitationFactory.create(
role=portfolio_role, status=InvitationStatus.REVOKED
)
assert portfolio_role.display_status == "Invite revoked"
@ -240,8 +237,8 @@ def test_status_when_invitation_is_expired():
portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
)
invitation = InvitationFactory.create(
portfolio_role=portfolio_role,
PortfolioInvitationFactory.create(
role=portfolio_role,
status=InvitationStatus.PENDING,
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=portfolio, user=user, status=PortfolioRoleStatus.PENDING
)
invitation = InvitationFactory.create(
portfolio_role=portfolio_role, status=InvitationStatus.ACCEPTED
PortfolioInvitationFactory.create(
role=portfolio_role, status=InvitationStatus.ACCEPTED
)
assert not portfolio_role.can_resend_invitation
@ -266,8 +263,8 @@ def test_can_resend_invitation_if_expired():
portfolio_role = PortfolioRoleFactory.create(
portfolio=portfolio, user=user, status=PortfolioRoleStatus.PENDING
)
invitation = InvitationFactory.create(
portfolio_role=portfolio_role, status=InvitationStatus.REJECTED_EXPIRED
PortfolioInvitationFactory.create(
role=portfolio_role, status=InvitationStatus.REJECTED_EXPIRED
)
assert portfolio_role.can_resend_invitation

View File

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

View File

@ -6,12 +6,11 @@ from tests.factories import (
UserFactory,
PortfolioFactory,
PortfolioRoleFactory,
InvitationFactory,
PortfolioInvitationFactory,
TaskOrderFactory,
)
from atst.domain.portfolios import Portfolios
from atst.models.portfolio_role import Status as PortfolioRoleStatus
from atst.models.invitation import Status as InvitationStatus
from atst.models import InvitationStatus, PortfolioRoleStatus
from atst.domain.users import Users
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(
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
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(
url_for("portfolios.create_member", portfolio_id=portfolio.id),
data={
"perms_app_mgmt": PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT,
"perms_funding": PermissionSets.VIEW_PORTFOLIO_FUNDING,
"perms_reporting": PermissionSets.VIEW_PORTFOLIO_REPORTS,
"perms_portfolio_mgmt": PermissionSets.VIEW_PORTFOLIO_ADMIN,
**user_info,
"permission_sets-perms_app_mgmt": PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT,
"permission_sets-perms_funding": PermissionSets.VIEW_PORTFOLIO_FUNDING,
"permission_sets-perms_reporting": PermissionSets.VIEW_PORTFOLIO_REPORTS,
"permission_sets-perms_portfolio_mgmt": PermissionSets.VIEW_PORTFOLIO_ADMIN,
"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
user = Users.get_by_dod_id(user_info["dod_id"])
token = user.invitations[0].token
token = user.portfolio_invitations[0].token
monkeypatch.setattr(
"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(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
)
invite = InvitationFactory.create(
user_id=user.id,
portfolio_role=ws_role,
status=InvitationStatus.REJECTED_WRONG_USER,
invite = PortfolioInvitationFactory.create(
user_id=user.id, role=ws_role, status=InvitationStatus.REJECTED_WRONG_USER
)
user_session(user)
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(
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)
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(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
)
invite = InvitationFactory.create(
invite = PortfolioInvitationFactory.create(
user_id=user.id,
portfolio_role=ws_role,
role=ws_role,
status=InvitationStatus.REJECTED_EXPIRED,
expiration_time=datetime.datetime.now() - datetime.timedelta(seconds=1),
)
@ -150,9 +150,9 @@ def test_revoke_invitation(client, user_session):
ws_role = PortfolioRoleFactory.create(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
)
invite = InvitationFactory.create(
invite = PortfolioInvitationFactory.create(
user_id=user.id,
portfolio_role=ws_role,
role=ws_role,
status=InvitationStatus.REJECTED_EXPIRED,
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(
user=user, portfolio=other_portfolio, status=PortfolioRoleStatus.PENDING
)
invite = InvitationFactory.create(
invite = PortfolioInvitationFactory.create(
user_id=user.id,
portfolio_role=portfolio_role,
role=portfolio_role,
status=InvitationStatus.REJECTED_EXPIRED,
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(
user=user, portfolio=other_portfolio, status=PortfolioRoleStatus.PENDING
)
invite = InvitationFactory.create(
invite = PortfolioInvitationFactory.create(
user_id=user.id,
portfolio_role=portfolio_role,
role=portfolio_role,
status=InvitationStatus.REJECTED_EXPIRED,
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(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
)
invite = InvitationFactory.create(
user_id=user.id, portfolio_role=ws_role, status=InvitationStatus.PENDING
invite = PortfolioInvitationFactory.create(
user_id=user.id, role=ws_role, status=InvitationStatus.PENDING
)
user_session(portfolio.owner)
client.post(
@ -250,9 +250,9 @@ def test_existing_member_invite_resent_to_email_submitted_in_form(
ws_role = PortfolioRoleFactory.create(
user=user, portfolio=portfolio, status=PortfolioRoleStatus.PENDING
)
invite = InvitationFactory.create(
invite = PortfolioInvitationFactory.create(
user_id=user.id,
portfolio_role=ws_role,
role=ws_role,
status=InvitationStatus.PENDING,
email="example@example.com",
)
@ -290,7 +290,7 @@ def test_contracting_officer_accepts_invite(monkeypatch, client, user_session):
# contracting officer accepts invitation
user = Users.get_by_dod_id(user_info["dod_id"])
token = user.invitations[0].token
token = user.portfolio_invitations[0].token
monkeypatch.setattr(
"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
user = Users.get_by_dod_id(user_info["dod_id"])
token = user.invitations[0].token
token = user.portfolio_invitations[0].token
monkeypatch.setattr(
"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
user = Users.get_by_dod_id(user_info["dod_id"])
token = user.invitations[0].token
token = user.portfolio_invitations[0].token
monkeypatch.setattr(
"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
_DEFAULT_PERMS_FORM_DATA = {
"perms_app_mgmt": PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT,
"perms_funding": PermissionSets.VIEW_PORTFOLIO_FUNDING,
"perms_reporting": PermissionSets.VIEW_PORTFOLIO_REPORTS,
"perms_portfolio_mgmt": PermissionSets.VIEW_PORTFOLIO_ADMIN,
"permission_sets-perms_app_mgmt": PermissionSets.VIEW_PORTFOLIO_APPLICATION_MANAGEMENT,
"permission_sets-perms_funding": PermissionSets.VIEW_PORTFOLIO_FUNDING,
"permission_sets-perms_reporting": PermissionSets.VIEW_PORTFOLIO_REPORTS,
"permission_sets-perms_portfolio_mgmt": PermissionSets.VIEW_PORTFOLIO_ADMIN,
}
@ -32,11 +32,11 @@ def test_create_member(client, user_session):
response = client.post(
url_for("portfolios.create_member", portfolio_id=portfolio.id),
data={
"dod_id": user.dod_id,
"first_name": "Wilbur",
"last_name": "Zuckerman",
"email": "some_pig@zuckermans.com",
"portfolio_role": "developer",
"user_data-dod_id": user.dod_id,
"user_data-first_name": "user_data-Wilbur",
"user_data-last_name": "user_data-Zuckerman",
"user_data-email": "user_data-some_pig@zuckermans.com",
"user_data-portfolio_role": "user_data-developer",
**_DEFAULT_PERMS_FORM_DATA,
},
follow_redirects=True,
@ -45,7 +45,7 @@ def test_create_member(client, user_session):
assert response.status_code == 200
assert user.full_name in response.data.decode()
assert user.has_portfolios
assert user.invitations
assert user.portfolio_invitations
assert len(queue.get_queue()) == queue_length + 1
portfolio_role = user.portfolio_roles[0]
assert len(portfolio_role.permission_sets) == 5

View File

@ -4,7 +4,7 @@ from flask import url_for
import pytest
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.queue import queue
@ -13,7 +13,7 @@ from tests.factories import (
TaskOrderFactory,
UserFactory,
PortfolioRoleFactory,
InvitationFactory,
PortfolioInvitationFactory,
)
@ -79,7 +79,7 @@ def test_does_not_resend_officer_invitation(client, user_session):
user_session(user)
for i in range(2):
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):
@ -272,9 +272,9 @@ class TestTaskOrderInvitations:
cor_invite=True,
)
portfolio_role = PortfolioRoleFactory.create(portfolio=self.portfolio, user=cor)
invitation = InvitationFactory.create(
PortfolioInvitationFactory.create(
inviter=self.portfolio.owner,
portfolio_role=portfolio_role,
role=portfolio_role,
user=cor,
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
)
original_invitation = InvitationFactory.create(
original_invitation = PortfolioInvitationFactory.create(
inviter=user,
portfolio_role=portfolio_role,
role=portfolio_role,
email=user.email,
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)
invite = InvitationFactory.create(
invite = PortfolioInvitationFactory.create(
inviter=user,
portfolio_role=portfolio_role,
role=portfolio_role,
email=user.email,
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_role = PortfolioRoleFactory.create(portfolio=portfolio, user=ko)
invite = InvitationFactory.create(
invite = PortfolioInvitationFactory.create(
inviter=portfolio.owner,
portfolio_role=portfolio_role,
role=portfolio_role,
email=ko.email,
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
def test_invite_member(queue):
def test_invite_portfolio_member(queue):
inviter = UserFactory.create()
new_member = UserFactory.create()
portfolio = PortfolioFactory.create(owner=inviter)
ws_member = PortfolioRoleFactory.create(user=new_member, portfolio=portfolio)
invite_service = Invitation(inviter, ws_member, new_member.email)
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

View File

@ -17,7 +17,7 @@ from tests.factories import (
ApplicationRoleFactory,
EnvironmentFactory,
EnvironmentRoleFactory,
InvitationFactory,
PortfolioInvitationFactory,
PortfolioFactory,
PortfolioRoleFactory,
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.task_orders.TaskOrders.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(
"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)
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(
"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)
task_order = TaskOrderFactory.create(portfolio=portfolio, contracting_officer=ko)
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(
"task_orders.resend_invite",
@ -449,7 +451,7 @@ def test_portfolios_revoke_invitation_access(post_url_assert_status):
prr = PortfolioRoleFactory.create(
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(
"portfolios.revoke_invitation",
portfolio_id=portfolio.id,

View File

@ -35,6 +35,9 @@ common:
save_and_continue: Save & continue
show: Show
sign: Sign
resource_names:
environments: Environments
choose_role: Choose a role
components:
modal:
close: Close
@ -65,6 +68,7 @@ flash:
next_steps: Review next steps below
portfolio_home: Go to my portfolio home page
success: Success!
new_application_member: 'You have successfully invited {user_name} to the team.'
footer:
about_link_text: Joint Enterprise Defense Infrastructure
browser_support: JEDI Cloud supported on these web browsers
@ -433,6 +437,14 @@ portfolios:
user: User
team_text: Team
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:
empty:
start_button: Start a new JEDI portfolio