feat(invites): add revoked_at + email_sent_at to account_invites

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 03:29:08 -04:00
parent 50e7763380
commit a28b635b19
3 changed files with 64 additions and 1 deletions

View File

@@ -0,0 +1,30 @@
"""account_invites add revoked_at and email_sent_at
Revision ID: 2aa73d3231c2
Revises: e1af7ab57ceb
Create Date: 2026-05-06 07:28:28.514384
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2aa73d3231c2'
down_revision: Union[str, None] = 'e1af7ab57ceb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("account_invites", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("account_invites", sa.Column("email_sent_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_account_invites_revoked_at", "account_invites", ["revoked_at"])
def downgrade() -> None:
op.drop_index("ix_account_invites_revoked_at", table_name="account_invites")
op.drop_column("account_invites", "email_sent_at")
op.drop_column("account_invites", "revoked_at")

View File

@@ -27,6 +27,8 @@ class AccountInvite(Base):
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
email_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Relationships
account: Mapped["Account"] = relationship("Account")
@@ -37,6 +39,10 @@ class AccountInvite(Base):
def is_used(self) -> bool:
return self.accepted_by_id is not None
@property
def is_revoked(self) -> bool:
return self.revoked_at is not None
@property
def is_expired(self) -> bool:
if self.expires_at is None:
@@ -45,4 +51,4 @@ class AccountInvite(Base):
@property
def is_valid(self) -> bool:
return not self.is_used and not self.is_expired
return not self.is_used and not self.is_expired and not self.is_revoked

View File

@@ -0,0 +1,27 @@
import pytest
from datetime import datetime, timezone, timedelta
from app.models.account_invite import AccountInvite
def make_invite(**kwargs):
return AccountInvite(
account_id=kwargs.get("account_id", "00000000-0000-0000-0000-000000000001"),
invited_by_id=kwargs.get("invited_by_id", "00000000-0000-0000-0000-000000000002"),
email=kwargs.get("email", "x@y.com"),
code=kwargs.get("code", "ABCD1234"),
role=kwargs.get("role", "engineer"),
accepted_by_id=kwargs.get("accepted_by_id"),
expires_at=kwargs.get("expires_at"),
revoked_at=kwargs.get("revoked_at"),
)
def test_invite_revoked_is_invalid():
invite = make_invite(revoked_at=datetime.now(timezone.utc))
assert invite.is_revoked is True
assert invite.is_valid is False
def test_invite_unrevoked_unexpired_unused_is_valid():
invite = make_invite(expires_at=datetime.now(timezone.utc) + timedelta(days=7))
assert invite.is_valid is True