Merge branch 'master' into require-personal-info
This commit is contained in:
@@ -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):
|
||||
|
||||
70
atst/domain/invitations.py
Normal file
70
atst/domain/invitations.py
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
71
atst/models/invitation.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user