Files
saas_backend/app/models/auth/invitation_model.py
T
2026-08-31 20:04:12 -04:00

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"