Merge branch 'master' into require-personal-info

This commit is contained in:
patricksmithdds 2018-10-31 14:12:55 -04:00 committed by GitHub
commit 0b44980ccb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 661 additions and 85 deletions

6
Pipfile.lock generated
View File

@ -322,11 +322,11 @@
},
"requests": {
"hashes": [
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
"sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c",
"sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279"
],
"index": "pypi",
"version": "==2.19.1"
"version": "==2.20.0"
},
"rq": {
"hashes": [

View File

@ -0,0 +1,28 @@
"""add provisional column to users
Revision ID: 5284ac1ac77c
Revises: e0fc3cd315c1
Create Date: 2018-10-25 11:04:49.879393
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5284ac1ac77c'
down_revision = 'e0fc3cd315c1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('provisional', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'provisional')
# ### end Alembic commands ###

View File

@ -0,0 +1,50 @@
"""add invitations
Revision ID: 994a80ee92c9
Revises: 9c24c609878a
Create Date: 2018-10-30 16:49:53.688621
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '994a80ee92c9'
down_revision = '9c24c609878a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('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('user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('workspace_role_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('inviter_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('status', sa.Enum('ACCEPTED', 'REVOKED', 'PENDING', 'REJECTED', 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.ForeignKeyConstraint(['inviter_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['workspace_role_id'], ['workspace_roles.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_invitations_inviter_id'), 'invitations', ['inviter_id'], unique=False)
op.create_index(op.f('ix_invitations_token'), 'invitations', ['token'], unique=False)
op.create_index(op.f('ix_invitations_user_id'), 'invitations', ['user_id'], unique=False)
op.create_index(op.f('ix_invitations_workspace_role_id'), 'invitations', ['workspace_role_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_invitations_workspace_role_id'), table_name='invitations')
op.drop_index(op.f('ix_invitations_user_id'), table_name='invitations')
op.drop_index(op.f('ix_invitations_token'), table_name='invitations')
op.drop_index(op.f('ix_invitations_inviter_id'), table_name='invitations')
op.drop_table('invitations')
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""status for existing workspace roles
Revision ID: a9d8c6b6221c
Revises: 5284ac1ac77c
Create Date: 2018-10-31 11:08:05.791739
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a9d8c6b6221c'
down_revision = '5284ac1ac77c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
conn = op.get_bind()
conn.execute("UPDATE workspace_roles set status = 'ACTIVE'")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
conn = op.get_bind()
conn.execute("UPDATE workspace_roles set status = null")
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""add status to workspace_roles
Revision ID: e0fc3cd315c1
Revises: 994a80ee92c9
Create Date: 2018-10-30 14:36:51.047876
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e0fc3cd315c1'
down_revision = '994a80ee92c9'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('workspace_roles', sa.Column('status', sa.Enum('ACTIVE', 'DISABLED', 'PENDING', name='status', native_enum=False), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('workspace_roles', 'status')
# ### end Alembic commands ###

View File

@ -6,8 +6,7 @@ from atst.domain.exceptions import UnauthorizedError
class Authorization(object):
@classmethod
def has_workspace_permission(cls, user, workspace, permission):
workspace_user = WorkspaceUsers.get(workspace.id, user.id)
return permission in workspace_user.permissions()
return permission in WorkspaceUsers.workspace_user_permissions(workspace, user)
@classmethod
def has_atat_permission(cls, user, permission):

View File

@ -0,0 +1,70 @@
import datetime
from sqlalchemy.orm.exc import NoResultFound
from atst.database import db
from atst.models.invitation import Invitation, Status as InvitationStatus
from atst.domain.workspace_users import WorkspaceUsers
from .exceptions import NotFoundError
class InvitationError(Exception):
def __init__(self, invite):
self.invite = invite
@property
def message(self):
return "{} has a status of {}".format(self.invite.id, self.invite.status.value)
class Invitations(object):
# 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()
except NoResultFound:
raise NotFoundError("invite")
return invite
@classmethod
def create(cls, workspace_role, inviter, user):
invite = Invitation(
workspace_role=workspace_role,
inviter=inviter,
user=user,
status=InvitationStatus.PENDING,
expiration_time=Invitations.current_expiration_time(),
)
db.session.add(invite)
db.session.commit()
return invite
@classmethod
def accept(cls, token):
invite = Invitations._get(token)
if invite.is_expired:
invite.status = InvitationStatus.REJECTED
elif invite.is_pending:
invite.status = InvitationStatus.ACCEPTED
db.session.add(invite)
db.session.commit()
if invite.is_revoked or invite.is_rejected:
raise InvitationError(invite)
WorkspaceUsers.enable(invite.workspace_role)
return invite
@classmethod
def current_expiration_time(cls):
return datetime.datetime.now() + datetime.timedelta(
minutes=Invitations.EXPIRATION_LIMIT_MINUTES
)

View File

@ -89,3 +89,12 @@ class Users(object):
db.session.commit()
return user
@classmethod
def finalize(cls, user):
user.provisional = False
db.session.add(user)
db.session.commit()
return user

View File

@ -1,7 +1,7 @@
from sqlalchemy.orm.exc import NoResultFound
from atst.database import db
from atst.models.workspace_role import WorkspaceRole
from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus
from atst.models.workspace_user import WorkspaceUser
from atst.models.user import User
@ -30,6 +30,30 @@ class WorkspaceUsers(object):
return WorkspaceUser(user, workspace_role)
@classmethod
def _get_active_workspace_role(cls, workspace_id, user_id):
try:
return (
db.session.query(WorkspaceRole)
.join(User)
.filter(User.id == user_id, WorkspaceRole.workspace_id == workspace_id)
.filter(WorkspaceRole.status == WorkspaceRoleStatus.ACTIVE)
.one()
)
except NoResultFound:
return None
@classmethod
def workspace_user_permissions(cls, workspace, user):
workspace_role = WorkspaceUsers._get_active_workspace_role(
workspace.id, user.id
)
atat_permissions = set(user.atat_role.permissions)
workspace_permissions = (
[] if workspace_role is None else workspace_role.role.permissions
)
return set(workspace_permissions).union(atat_permissions)
@classmethod
def _get_workspace_role(cls, user, workspace_id):
try:
@ -123,3 +147,10 @@ class WorkspaceUsers(object):
db.session.commit()
return workspace_users
@classmethod
def enable(cls, workspace_role):
workspace_role.status = WorkspaceRoleStatus.ACTIVE
db.session.add(workspace_role)
db.session.commit()

View File

@ -4,7 +4,7 @@ from atst.database import db
from atst.domain.common import Query
from atst.domain.exceptions import NotFoundError
from atst.models.workspace import Workspace
from atst.models.workspace_role import WorkspaceRole
from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus
class WorkspacesQuery(Query):
@ -25,9 +25,10 @@ class WorkspacesQuery(Query):
db.session.query(Workspace)
.join(WorkspaceRole)
.filter(WorkspaceRole.user == user)
.filter(WorkspaceRole.status == WorkspaceRoleStatus.ACTIVE)
.all()
)
@classmethod
def create_workspace_role(cls, user, role, workspace):
return WorkspaceRole(user=user, role=role, workspace=workspace)
def create_workspace_role(cls, user, role, workspace, **kwargs):
return WorkspaceRole(user=user, role=role, workspace=workspace, **kwargs)

View File

@ -3,6 +3,7 @@ from atst.domain.authz import Authorization
from atst.models.permissions import Permissions
from atst.domain.users import Users
from atst.domain.workspace_users import WorkspaceUsers
from atst.models.workspace_role import Status as WorkspaceRoleStatus
from .query import WorkspacesQuery
from .scopes import ScopedWorkspace
@ -13,7 +14,9 @@ class Workspaces(object):
def create(cls, request, name=None):
name = name or request.displayname
workspace = WorkspacesQuery.create(request=request, name=name)
Workspaces._create_workspace_role(request.creator, workspace, "owner")
Workspaces._create_workspace_role(
request.creator, workspace, "owner", status=WorkspaceRoleStatus.ACTIVE
)
WorkspacesQuery.add_and_commit(workspace)
return workspace
@ -86,6 +89,7 @@ class Workspaces(object):
last_name=data["last_name"],
email=data["email"],
atat_role_name="default",
provisional=True,
)
return Workspaces.add_member(workspace, new_user, data["workspace_role"])
@ -106,9 +110,13 @@ class Workspaces(object):
return WorkspaceUsers.update_role(member, workspace.id, role_name)
@classmethod
def _create_workspace_role(cls, user, workspace, role_name):
def _create_workspace_role(
cls, user, workspace, role_name, status=WorkspaceRoleStatus.PENDING
):
role = Roles.get(role_name)
workspace_role = WorkspacesQuery.create_workspace_role(user, role, workspace)
workspace_role = WorkspacesQuery.create_workspace_role(
user, role, workspace, status=status
)
WorkspacesQuery.add_and_commit(workspace_role)
return workspace_role

View File

@ -18,3 +18,4 @@ from .request_revision import RequestRevision
from .request_review import RequestReview
from .request_internal_comment import RequestInternalComment
from .audit_event import AuditEvent
from .invitation import Invitation

71
atst/models/invitation.py Normal file
View File

@ -0,0 +1,71 @@
import datetime
from enum import Enum
import secrets
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum, TIMESTAMP, String
from sqlalchemy.dialects.postgresql import UUID
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
class Status(Enum):
ACCEPTED = "accepted"
REVOKED = "revoked"
PENDING = "pending"
REJECTED = "rejected"
class Invitation(Base, TimestampsMixin, AuditableMixin):
__tablename__ = "invitations"
id = types.Id()
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
user = relationship("User", backref="invitations", foreign_keys=[user_id])
workspace_role_id = Column(
UUID(as_uuid=True), ForeignKey("workspace_roles.id"), index=True
)
workspace_role = relationship("WorkspaceRole", backref="invitations")
inviter_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
inviter = relationship("User", backref="sent_invites", foreign_keys=[inviter_id])
status = Column(SQLAEnum(Status, native_enum=False, default=Status.PENDING))
expiration_time = Column(TIMESTAMP(timezone=True))
token = Column(String(), index=True, default=lambda: secrets.token_urlsafe())
def __repr__(self):
return "<Invitation(user='{}', workspace_role='{}', id='{}')>".format(
self.user_id, self.workspace_role_id, self.id
)
@property
def is_accepted(self):
return self.status == Status.ACCEPTED
@property
def is_revoked(self):
return self.status == Status.REVOKED
@property
def is_pending(self):
return self.status == Status.PENDING
@property
def is_rejected(self):
return self.status == Status.REJECTED
@property
def is_expired(self):
return datetime.datetime.now(self.expiration_time.tzinfo) > self.expiration_time
@property
def workspace(self):
if self.workspace_role:
return self.workspace_role.workspace

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, ForeignKey, Column, Date
from sqlalchemy import String, ForeignKey, Column, Date, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
@ -25,6 +25,8 @@ class User(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
citizenship = Column(String)
designation = Column(String)
date_latest_training = Column(Date)
provisional = Column(Boolean)
REQUIRED_FIELDS = [
"email",

View File

@ -49,6 +49,6 @@ class Workspace(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
return self.id
def __repr__(self):
return "<Workspace(name='{}', request='{}', task_order='{}', user_count='{}', id='{}')>".format(
self.name, self.request_id, self.task_order.number, self.user_count, self.id
return "<Workspace(name='{}', request='{}', user_count='{}', id='{}')>".format(
self.name, self.request_id, self.user_count, self.id
)

View File

@ -1,4 +1,5 @@
from sqlalchemy import Index, ForeignKey, Column
from enum import Enum
from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
@ -6,6 +7,12 @@ from atst.models import Base, mixins
from .types import Id
class Status(Enum):
ACTIVE = "active"
DISABLED = "disabled"
PENDING = "pending"
class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
__tablename__ = "workspace_roles"
@ -22,6 +29,8 @@ class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=False
)
status = Column(SQLAEnum(Status, native_enum=False, default=Status.PENDING))
def __repr__(self):
return "<WorkspaceRole(role='{}', workspace='{}', user_id='{}', id='{}')>".format(
self.role.name, self.workspace.name, self.user_id, self.id

View File

@ -9,13 +9,6 @@ class WorkspaceUser(object):
self.user = user
self.workspace_role = workspace_role
def permissions(self):
atat_permissions = set(self.user.atat_role.permissions)
workspace_permissions = (
[] if self.workspace_role is None else self.workspace_role.role.permissions
)
return set(workspace_permissions).union(atat_permissions)
@property
def workspace(self):
return self.workspace_role.workspace
@ -74,8 +67,8 @@ class WorkspaceUser(object):
def __repr__(self):
return "<WorkspaceUser(user='{}', role='{}', workspace='{}', num_environment_roles='{}')>".format(
self.user_name,
self.role.name,
self.user.full_name,
self.role,
self.workspace.name,
self.num_environment_roles,
)

View File

@ -101,6 +101,10 @@ def login_redirect():
auth_context = _make_authentication_context()
auth_context.authenticate()
user = auth_context.get_user()
if user.provisional:
Users.finalize(user)
session["user_id"] = user.id
return redirect(redirect_after_login_url())

View File

@ -3,6 +3,7 @@ from flask_wtf.csrf import CSRFError
import werkzeug.exceptions as werkzeug_exceptions
import atst.domain.exceptions as exceptions
from atst.domain.invitations import InvitationError
def make_error_pages(app):
@ -41,4 +42,15 @@ def make_error_pages(app):
500,
)
@app.errorhandler(InvitationError)
# pylint: disable=unused-variable
def invalid_invitation(e):
log_error(e)
return (
render_template(
"error.html", message="The invitation link you clicked is invalid."
),
404,
)
return app

View File

@ -24,6 +24,8 @@ from atst.forms.workspace import WorkspaceForm
from atst.forms.data import ENVIRONMENT_ROLES, ENV_ROLE_MODAL_DESCRIPTION
from atst.domain.authz import Authorization
from atst.models.permissions import Permissions
from atst.domain.invitations import Invitations
from atst.queue import queue
bp = Blueprint("workspaces", __name__)
@ -218,6 +220,15 @@ def new_member(workspace_id):
)
def send_invite_email(owner_name, token, new_member_email):
body = render_template("emails/invitation.txt", owner=owner_name, token=token)
queue.send_mail(
[new_member_email],
"{} has invited you to a JEDI Cloud Workspace".format(owner_name),
body,
)
@bp.route("/workspaces/<workspace_id>/members/new", methods=["POST"])
def create_member(workspace_id):
workspace = Workspaces.get(g.current_user, workspace_id)
@ -226,6 +237,13 @@ def create_member(workspace_id):
if form.validate():
try:
new_member = Workspaces.create_member(g.current_user, workspace, form.data)
invite = Invitations.create(
new_member.workspace_role, g.current_user, new_member.user
)
send_invite_email(
g.current_user.full_name, invite.token, new_member.user.email
)
return redirect(
url_for(
"workspaces.workspace_members",
@ -318,3 +336,14 @@ def update_member(workspace_id, member_id):
workspace=workspace,
member=member,
)
@bp.route("/workspaces/invitation/<token>", methods=["GET"])
def accept_invitation(token):
# TODO: check that the current_user DOD ID matches the user associated with
# the invitation
invite = Invitations.accept(token)
return redirect(
url_for("workspaces.show_workspace", workspace_id=invite.workspace.id)
)

View File

@ -0,0 +1,12 @@
Join this JEDI Cloud Workspace
{{ owner }} has invited you to join a JEDI Cloud Workspace. Login now to view or use your JEDI Cloud resources.
{{ url_for("workspaces.accept_invitation", token=token, _external=True) }}
What is JEDI Cloud?
JEDI Cloud is a DoD enterprise-wide solution for commercial cloud services.
What is a JEDI Cloud Workspace?
A JEDI Cloud Workspace is where you may access and manage the cloud resources associated with your projects and environments.
JEDI Cloud is managed by the Cloud Computing Program Office. Learn more at jedi.cloud.

View File

@ -0,0 +1,62 @@
import datetime
import pytest
import re
from atst.domain.invitations import Invitations, InvitationError
from atst.models.invitation import Status
from tests.factories import (
WorkspaceFactory,
WorkspaceRoleFactory,
UserFactory,
InvitationFactory,
)
def test_create_invitation():
workspace = WorkspaceFactory.create()
user = UserFactory.create()
ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace)
invite = Invitations.create(ws_role, workspace.owner, user)
assert invite.user == user
assert invite.workspace_role == ws_role
assert invite.inviter == workspace.owner
assert invite.status == Status.PENDING
assert re.match(r"^[\w\-_]+$", invite.token)
def test_accept_invitation():
workspace = WorkspaceFactory.create()
user = UserFactory.create()
ws_role = WorkspaceRoleFactory.create(user=user, workspace=workspace)
invite = Invitations.create(ws_role, workspace.owner, user)
assert invite.is_pending
accepted_invite = Invitations.accept(invite.token)
assert accepted_invite.is_accepted
def test_accept_expired_invitation():
user = UserFactory.create()
increment = Invitations.EXPIRATION_LIMIT_MINUTES + 1
expiration_time = datetime.datetime.now() - datetime.timedelta(minutes=increment)
invite = InvitationFactory.create(
user_id=user.id, expiration_time=expiration_time, status=Status.PENDING
)
with pytest.raises(InvitationError):
Invitations.accept(invite.token)
assert invite.is_rejected
def test_accept_rejected_invite():
user = UserFactory.create()
invite = InvitationFactory.create(user_id=user.id, status=Status.REJECTED)
with pytest.raises(InvitationError):
Invitations.accept(invite.token)
def test_accept_revoked_invite():
user = UserFactory.create()
invite = InvitationFactory.create(user_id=user.id, status=Status.REVOKED)
with pytest.raises(InvitationError):
Invitations.accept(invite.token)

View File

@ -1,6 +1,14 @@
from atst.domain.workspace_users import WorkspaceUsers
from atst.domain.users import Users
from tests.factories import WorkspaceFactory, UserFactory
from atst.models.workspace_role import Status as WorkspaceRoleStatus
from atst.domain.roles import Roles
from tests.factories import (
WorkspaceFactory,
UserFactory,
InvitationFactory,
WorkspaceRoleFactory,
)
def test_can_create_new_workspace_user():
@ -34,3 +42,24 @@ def test_can_update_existing_workspace_user():
workspace_users[0].workspace_role.role.name
== new_user.workspace_roles[0].role.name
)
def test_workspace_user_permissions():
workspace_one = WorkspaceFactory.create()
workspace_two = WorkspaceFactory.create()
new_user = UserFactory.create()
WorkspaceRoleFactory.create(
workspace=workspace_one,
user=new_user,
role=Roles.get("developer"),
status=WorkspaceRoleStatus.ACTIVE,
)
WorkspaceRoleFactory.create(
workspace=workspace_two,
user=new_user,
role=Roles.get("developer"),
status=WorkspaceRoleStatus.PENDING,
)
assert WorkspaceUsers.workspace_user_permissions(workspace_one, new_user)
assert not WorkspaceUsers.workspace_user_permissions(workspace_two, new_user)

View File

@ -6,8 +6,14 @@ from atst.domain.workspaces import Workspaces
from atst.domain.workspace_users import WorkspaceUsers
from atst.domain.projects import Projects
from atst.domain.environments import Environments
from atst.models.workspace_role import Status as WorkspaceRoleStatus
from tests.factories import RequestFactory, UserFactory
from tests.factories import (
RequestFactory,
UserFactory,
InvitationFactory,
WorkspaceRoleFactory,
)
@pytest.fixture(scope="function")
@ -86,6 +92,23 @@ def test_can_create_workspace_user(workspace, workspace_owner):
new_member = Workspaces.create_member(workspace_owner, workspace, user_data)
assert new_member.workspace == workspace
assert new_member.user.provisional
def test_can_add_existing_user_to_workspace(workspace, workspace_owner):
user = UserFactory.create()
user_data = {
"first_name": "New",
"last_name": "User",
"email": "new.user@mail.com",
"workspace_role": "developer",
"dod_id": user.dod_id,
}
new_member = Workspaces.create_member(workspace_owner, workspace, user_data)
assert new_member.workspace == workspace
assert new_member.user.email == user.email
assert not new_member.user.provisional
def test_need_permission_to_create_workspace_user(workspace, workspace_owner):
@ -199,9 +222,10 @@ def test_scoped_workspace_returns_all_projects_for_workspace_admin(
["dev", "staging", "prod"],
)
admin = Workspaces.add_member(
workspace, UserFactory.from_atat_role("default"), "admin"
).user
admin = UserFactory.from_atat_role("default")
Workspaces._create_workspace_role(
admin, workspace, "admin", status=WorkspaceRoleStatus.ACTIVE
)
scoped_workspace = Workspaces.get(admin, workspace.id)
assert len(scoped_workspace.projects) == 5
@ -226,13 +250,25 @@ def test_scoped_workspace_returns_all_projects_for_workspace_owner(
assert len(scoped_workspace.projects[0].environments) == 3
def test_for_user_returns_assigned_workspaces_for_user(workspace, workspace_owner):
def test_for_user_returns_active_workspaces_for_user(workspace, workspace_owner):
bob = UserFactory.from_atat_role("default")
WorkspaceRoleFactory.create(
user=bob, workspace=workspace, status=WorkspaceRoleStatus.ACTIVE
)
Workspaces.create(RequestFactory.create())
bobs_workspaces = Workspaces.for_user(bob)
assert len(bobs_workspaces) == 1
def test_for_user_does_not_return_inactive_workspaces(workspace, workspace_owner):
bob = UserFactory.from_atat_role("default")
Workspaces.add_member(workspace, bob, "developer")
Workspaces.create(RequestFactory.create())
bobs_workspaces = Workspaces.for_user(bob)
assert len(bobs_workspaces) == 1
assert len(bobs_workspaces) == 0
def test_for_user_returns_all_workspaces_for_ccpo(workspace, workspace_owner):
@ -250,7 +286,9 @@ def test_get_for_update_information():
assert workspace == owner_ws
admin = UserFactory.create()
Workspaces.add_member(workspace, admin, "admin")
Workspaces._create_workspace_role(
admin, workspace, "admin", status=WorkspaceRoleStatus.ACTIVE
)
admin_ws = Workspaces.get_for_update_information(admin, workspace.id)
assert workspace == admin_ws

View File

@ -18,8 +18,11 @@ from atst.models.user import User
from atst.models.role import Role
from atst.models.workspace import Workspace
from atst.domain.roles import Roles
from atst.models.workspace_role import WorkspaceRole
from atst.models.workspace_role import WorkspaceRole, Status as WorkspaceRoleStatus
from atst.models.environment_role import EnvironmentRole
from atst.models.invitation import Invitation, Status as InvitationStatus
from atst.domain.workspaces import Workspaces
from atst.domain.invitations import Invitations
class Base(factory.alchemy.SQLAlchemyModelFactory):
@ -243,7 +246,7 @@ class WorkspaceFactory(Base):
@classmethod
def _create(cls, model_class, *args, **kwargs):
with_projects = kwargs.pop("projects", [])
owner = kwargs.pop("owner", None)
owner = kwargs.pop("owner", UserFactory.create())
members = kwargs.pop("members", [])
workspace = super()._create(model_class, *args, **kwargs)
@ -252,17 +255,22 @@ class WorkspaceFactory(Base):
ProjectFactory.create(workspace=workspace, **p) for p in with_projects
]
if owner:
workspace.request.creator = owner
WorkspaceRoleFactory.create(
workspace=workspace, role=Roles.get("owner"), user=owner
)
workspace.request.creator = owner
WorkspaceRoleFactory.create(
workspace=workspace,
role=Roles.get("owner"),
user=owner,
status=WorkspaceRoleStatus.ACTIVE,
)
for member in members:
user = member.get("user", UserFactory.create())
role_name = member["role_name"]
WorkspaceRoleFactory.create(
workspace=workspace, role=Roles.get(role_name), user=user
workspace=workspace,
role=Roles.get(role_name),
user=user,
status=WorkspaceRoleStatus.ACTIVE,
)
workspace.projects = projects
@ -325,3 +333,11 @@ class EnvironmentRoleFactory(Base):
environment = factory.SubFactory(EnvironmentFactory)
role = factory.Faker("name")
user = factory.SubFactory(UserFactory)
class InvitationFactory(Base):
class Meta:
model = Invitation
status = InvitationStatus.PENDING
expiration_time = Invitations.current_expiration_time()

View File

@ -3,11 +3,8 @@ from atst.domain.workspaces import Workspaces
def test_user_with_workspaces_has_workspaces_nav(client, user_session):
user = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(user, workspace, "developer")
user_session(user)
user_session(workspace.owner)
response = client.get("/home", follow_redirects=True)
assert b'href="/workspaces"' in response.data

View File

@ -1,20 +1,25 @@
from flask import url_for
from tests.factories import UserFactory, WorkspaceFactory
from tests.factories import (
UserFactory,
WorkspaceFactory,
WorkspaceRoleFactory,
InvitationFactory,
)
from atst.domain.workspaces import Workspaces
from atst.domain.workspace_users import WorkspaceUsers
from atst.domain.projects import Projects
from atst.domain.environments import Environments
from atst.domain.environment_roles import EnvironmentRoles
from atst.models.workspace_user import WorkspaceUser
from atst.models.workspace_role import Status as WorkspaceRoleStatus
from atst.models.invitation import Status as InvitationStatus
from atst.queue import queue
def test_user_with_permission_has_budget_report_link(client, user_session):
user = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(user, workspace, "owner")
user_session(user)
user_session(workspace.owner)
response = client.get("/workspaces/{}/projects".format(workspace.id))
assert (
'href="/workspaces/{}/reports"'.format(workspace.id).encode() in response.data
@ -24,7 +29,9 @@ def test_user_with_permission_has_budget_report_link(client, user_session):
def test_user_without_permission_has_no_budget_report_link(client, user_session):
user = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(user, workspace, "developer")
Workspaces._create_workspace_role(
user, workspace, "developer", status=WorkspaceRoleStatus.ACTIVE
)
user_session(user)
response = client.get("/workspaces/{}/projects".format(workspace.id))
assert (
@ -34,11 +41,8 @@ def test_user_without_permission_has_no_budget_report_link(client, user_session)
def test_user_with_permission_has_add_project_link(client, user_session):
user = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(user, workspace, "owner")
user_session(user)
user_session(workspace.owner)
response = client.get("/workspaces/{}/projects".format(workspace.id))
assert (
'href="/workspaces/{}/projects/new"'.format(workspace.id).encode()
@ -59,11 +63,8 @@ def test_user_without_permission_has_no_add_project_link(client, user_session):
def test_user_with_permission_has_add_member_link(client, user_session):
user = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(user, workspace, "owner")
user_session(user)
user_session(workspace.owner)
response = client.get("/workspaces/{}/members".format(workspace.id))
assert (
'href="/workspaces/{}/members/new"'.format(workspace.id).encode()
@ -84,10 +85,8 @@ def test_user_without_permission_has_no_add_member_link(client, user_session):
def test_update_workspace_name(client, user_session):
user = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(user, workspace, "admin")
user_session(user)
user_session(workspace.owner)
response = client.post(
url_for("workspaces.edit_workspace", workspace_id=workspace.id),
data={"name": "a cool new name"},
@ -98,17 +97,15 @@ def test_update_workspace_name(client, user_session):
def test_view_edit_project(client, user_session):
owner = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(owner, workspace, "admin")
project = Projects.create(
owner,
workspace.owner,
workspace,
"Snazzy Project",
"A new project for me and my friends",
{"env1", "env2"},
)
user_session(owner)
user_session(workspace.owner)
response = client.get(
"/workspaces/{}/projects/{}/edit".format(workspace.id, project.id)
)
@ -176,11 +173,11 @@ def test_user_without_permission_cannot_update_project(client, user_session):
def test_create_member(client, user_session):
owner = UserFactory.create()
user = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(owner, workspace, "admin")
user_session(owner)
user_session(workspace.owner)
queue_length = len(queue.get_queue())
response = client.post(
url_for("workspaces.create_member", workspace_id=workspace.id),
data={
@ -195,6 +192,8 @@ def test_create_member(client, user_session):
assert response.status_code == 200
assert user.has_workspaces
assert user.invitations
assert len(queue.get_queue()) == queue_length + 1
def test_permissions_for_view_member(client, user_session):
@ -211,12 +210,10 @@ def test_permissions_for_view_member(client, user_session):
def test_update_member_workspace_role(client, user_session):
owner = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(owner, workspace, "admin")
user = UserFactory.create()
member = WorkspaceUsers.add(user, workspace.id, "developer")
user_session(owner)
user_session(workspace.owner)
response = client.post(
url_for(
"workspaces.update_member", workspace_id=workspace.id, member_id=user.id
@ -229,12 +226,10 @@ def test_update_member_workspace_role(client, user_session):
def test_update_member_workspace_role_with_no_data(client, user_session):
owner = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(owner, workspace, "admin")
user = UserFactory.create()
member = WorkspaceUsers.add(user, workspace.id, "developer")
user_session(owner)
user_session(workspace.owner)
response = client.post(
url_for(
"workspaces.update_member", workspace_id=workspace.id, member_id=user.id
@ -247,14 +242,11 @@ def test_update_member_workspace_role_with_no_data(client, user_session):
def test_update_member_environment_role(client, user_session):
owner = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(owner, workspace, "admin")
user = UserFactory.create()
member = WorkspaceUsers.add(user, workspace.id, "developer")
project = Projects.create(
owner,
workspace.owner,
workspace,
"Snazzy Project",
"A new project for me and my friends",
@ -264,7 +256,7 @@ def test_update_member_environment_role(client, user_session):
env2_id = project.environments[1].id
for env in project.environments:
Environments.add_member(env, user, "developer")
user_session(owner)
user_session(workspace.owner)
response = client.post(
url_for(
"workspaces.update_member", workspace_id=workspace.id, member_id=user.id
@ -282,14 +274,11 @@ def test_update_member_environment_role(client, user_session):
def test_update_member_environment_role_with_no_data(client, user_session):
owner = UserFactory.create()
workspace = WorkspaceFactory.create()
Workspaces._create_workspace_role(owner, workspace, "admin")
user = UserFactory.create()
member = WorkspaceUsers.add(user, workspace.id, "developer")
project = Projects.create(
owner,
workspace.owner,
workspace,
"Snazzy Project",
"A new project for me and my friends",
@ -298,7 +287,7 @@ def test_update_member_environment_role_with_no_data(client, user_session):
env1_id = project.environments[0].id
for env in project.environments:
Environments.add_member(env, user, "developer")
user_session(owner)
user_session(workspace.owner)
response = client.post(
url_for(
"workspaces.update_member", workspace_id=workspace.id, member_id=user.id
@ -308,3 +297,61 @@ def test_update_member_environment_role_with_no_data(client, user_session):
)
assert response.status_code == 200
assert EnvironmentRoles.get(user.id, env1_id).role == "developer"
def test_new_member_accepts_valid_invite(client, user_session):
workspace = WorkspaceFactory.create()
user = UserFactory.create()
ws_role = WorkspaceRoleFactory.create(
workspace=workspace, user=user, status=WorkspaceRoleStatus.PENDING
)
invite = InvitationFactory.create(user_id=user.id, workspace_role_id=ws_role.id)
# the user does not have access to the workspace before accepting the invite
assert len(Workspaces.for_user(user)) == 0
user_session(user)
response = client.get(url_for("workspaces.accept_invitation", token=invite.token))
# user is redirected to the workspace view
assert response.status_code == 302
assert (
url_for("workspaces.show_workspace", workspace_id=invite.workspace.id)
in response.headers["Location"]
)
# the one-time use invite is no longer usable
assert invite.is_accepted
# the user has access to the workspace
assert len(Workspaces.for_user(user)) == 1
def test_new_member_accept_invalid_invite(client, user_session):
workspace = WorkspaceFactory.create()
user = UserFactory.create()
ws_role = WorkspaceRoleFactory.create(
user=user, workspace=workspace, status=WorkspaceRoleStatus.PENDING
)
invite = InvitationFactory.create(
user_id=user.id, workspace_role_id=ws_role.id, status=InvitationStatus.REJECTED
)
user_session(user)
response = client.get(url_for("workspaces.accept_invitation", token=invite.token))
assert response.status_code == 404
def test_user_who_has_not_accepted_workspace_invite_cannot_view(client, user_session):
user = UserFactory.create()
workspace = WorkspaceFactory.create()
# create user in workspace with invitation
user_session(workspace.owner)
response = client.post(
url_for("workspaces.create_member", workspace_id=workspace.id),
data={"workspace_role": "developer", **user.to_dictionary()},
)
# user tries to view workspace before accepting invitation
user_session(user)
response = client.get("/workspaces/{}/projects".format(workspace.id))
assert response.status_code == 404