diff --git a/alembic/versions/25bcba9b99a9_add_invitiations.py b/alembic/versions/25bcba9b99a9_add_invitiations.py new file mode 100644 index 00000000..c2681435 --- /dev/null +++ b/alembic/versions/25bcba9b99a9_add_invitiations.py @@ -0,0 +1,42 @@ +"""add invitiations + +Revision ID: 25bcba9b99a9 +Revises: c99026ab9918 +Create Date: 2018-10-23 15:03:12.641069 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '25bcba9b99a9' +down_revision = 'c99026ab9918' +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_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('valid', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_invitations_user_id'), 'invitations', ['user_id'], unique=False) + op.create_index(op.f('ix_invitations_workspace_id'), 'invitations', ['workspace_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_invitations_workspace_id'), table_name='invitations') + op.drop_index(op.f('ix_invitations_user_id'), table_name='invitations') + op.drop_table('invitations') + # ### end Alembic commands ### diff --git a/atst/domain/invitations.py b/atst/domain/invitations.py new file mode 100644 index 00000000..2f067b36 --- /dev/null +++ b/atst/domain/invitations.py @@ -0,0 +1,62 @@ +import datetime +from sqlalchemy.orm.exc import NoResultFound + +from atst.database import db +from atst.models import Invitation + +from .exceptions import NotFoundError + + +class InvitationExpired(Exception): + def __init__(self, invite_id): + self.invite_id = invite_id + + @property + def message(self): + return "{} has expired".format(self.invite_id) + + +class Invitations(object): + EXPIRATION_LIMIT = 360 + + @classmethod + def _get(cls, invite_id): + try: + invite = db.session.query(Invitation).filter_by(id=invite_id).one() + except NoResultFound: + raise NotFoundError("invite") + + return invite + + @classmethod + def create(cls, workspace, user): + invite = Invitation(workspace=workspace, user=user, valid=True) + db.session.add(invite) + db.session.commit() + + return invite + + @classmethod + def accept(cls, invite_id): + invite = Invitations._get(invite_id) + valid = Invitations._is_valid(invite) + + invite.valid = False + db.session.add(invite) + db.session.commit() + + if not valid: + raise InvitationExpired(invite_id) + + return invite + + @classmethod + def _is_valid(cls, invite): + if not invite.valid: + return False + else: + time_created = invite.time_created + expiration = datetime.datetime.now( + time_created.tzinfo + ) - datetime.timedelta(minutes=Invitations.EXPIRATION_LIMIT) + return invite.time_created > expiration diff --git a/atst/models/__init__.py b/atst/models/__init__.py index 91cee016..27afc327 100644 --- a/atst/models/__init__.py +++ b/atst/models/__init__.py @@ -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 diff --git a/atst/models/invitation.py b/atst/models/invitation.py new file mode 100644 index 00000000..97158370 --- /dev/null +++ b/atst/models/invitation.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, ForeignKey, Boolean +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship + +from atst.models import Base, types +from atst.models.mixins.timestamps import TimestampsMixin + + +class Invitation(Base, TimestampsMixin): + __tablename__ = "invitations" + + id = types.Id() + + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + user = relationship("User", backref="invitations") + + workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), index=True) + workspace = relationship("Workspace", backref="invitations") + + valid = Column(Boolean, default=True) + + def __repr__(self): + return "".format( + self.user.id, self.workspace.id, self.id + ) diff --git a/tests/domain/test_invitations.py b/tests/domain/test_invitations.py new file mode 100644 index 00000000..b755bc3a --- /dev/null +++ b/tests/domain/test_invitations.py @@ -0,0 +1,48 @@ +import datetime +import pytest + +from atst.domain.invitations import Invitations, InvitationExpired + +from tests.factories import WorkspaceFactory, UserFactory, InvitationFactory + + +def test_create_invitation(): + workspace = WorkspaceFactory.create() + user = UserFactory.create() + invite = Invitations.create(workspace, user) + assert invite.user == user + assert invite.workspace == workspace + assert invite.valid + + +def test_accept_invitation(): + workspace = WorkspaceFactory.create() + user = UserFactory.create() + invite = Invitations.create(workspace, user) + assert invite.valid + accepted_invite = Invitations.accept(invite.id) + assert not accepted_invite.valid + + +def test_accept_expired_invitation(): + workspace = WorkspaceFactory.create() + user = UserFactory.create() + increment = Invitations.EXPIRATION_LIMIT + 1 + created_at = datetime.datetime.now() - datetime.timedelta(minutes=increment) + invite = InvitationFactory.create( + workspace_id=workspace.id, user_id=user.id, time_created=created_at, valid=True + ) + with pytest.raises(InvitationExpired): + Invitations.accept(invite.id) + + assert not invite.valid + + +def test_accept_invalid_invite(): + workspace = WorkspaceFactory.create() + user = UserFactory.create() + invite = InvitationFactory.create( + workspace_id=workspace.id, user_id=user.id, valid=False + ) + with pytest.raises(InvitationExpired): + Invitations.accept(invite.id) diff --git a/tests/factories.py b/tests/factories.py index b2338a06..ca40e61c 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -20,6 +20,7 @@ from atst.models.workspace import Workspace from atst.domain.roles import Roles from atst.models.workspace_role import WorkspaceRole from atst.models.environment_role import EnvironmentRole +from atst.models.invitation import Invitation class Base(factory.alchemy.SQLAlchemyModelFactory): @@ -325,3 +326,8 @@ class EnvironmentRoleFactory(Base): environment = factory.SubFactory(EnvironmentFactory) role = factory.Faker("name") user = factory.SubFactory(UserFactory) + + +class InvitationFactory(Base): + class Meta: + model = Invitation