71 lines
2.7 KiB
Python
71 lines
2.7 KiB
Python
"""An invitation to join a workspace.
|
|
|
|
The token itself is never stored — only its SHA-256 — so this model can check an
|
|
invitation but cannot reproduce one. That is deliberate: a "resend" makes a new
|
|
token rather than repeating the old one, because nothing on the platform is able
|
|
to repeat it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import Column, DateTime, ForeignKey, String, func
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from app.config.database import Base
|
|
|
|
|
|
def hash_token(raw: str) -> str:
|
|
"""One place, so the write and the lookup cannot disagree."""
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
class UserInvitation(Base):
|
|
__tablename__ = "user_invitations"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True,
|
|
server_default=func.gen_random_uuid())
|
|
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
|
nullable=False)
|
|
email = Column(String(255), nullable=False)
|
|
first_name = Column(String(100))
|
|
last_name = Column(String(100))
|
|
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="SET NULL"))
|
|
invited_by_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
|
|
token_hash = Column(String(64), nullable=False)
|
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
|
accepted_at = Column(DateTime(timezone=True))
|
|
revoked_at = Column(DateTime(timezone=True))
|
|
accepted_user_id = Column(UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="SET NULL"))
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
|
|
|
tenant = relationship("Tenant", lazy="raise")
|
|
role = relationship("Role", lazy="raise")
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
return self.expires_at <= datetime.now(timezone.utc)
|
|
|
|
@property
|
|
def is_pending(self) -> bool:
|
|
"""Still usable. Everything an accept route needs to know, in one place,
|
|
because three separate checks is how one of them gets forgotten."""
|
|
return not (self.accepted_at or self.revoked_at or self.is_expired)
|
|
|
|
@property
|
|
def state(self) -> str:
|
|
"""For the administrator's list. An expired invitation and a revoked one
|
|
look identical to the invitee — both refuse — but they mean different
|
|
things to whoever sent it."""
|
|
if self.accepted_at:
|
|
return "accepted"
|
|
if self.revoked_at:
|
|
return "revoked"
|
|
if self.is_expired:
|
|
return "expired"
|
|
return "pending"
|