Clawith/backend/app/models/user.py

119 lines
4.8 KiB
Python

"""User and organization models."""
import uuid
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.ext.associationproxy import association_proxy
from app.database import Base
class Identity(Base):
"""
Physical Identity (Lark ID).
Represents a natural person globally across all tenants.
"""
__tablename__ = "identities"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Global unique identifiers for login
email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True)
phone: Mapped[str | None] = mapped_column(String(50), unique=True, index=True)
username: Mapped[str | None] = mapped_column(String(100), unique=True, index=True)
# Global authentication
password_hash: Mapped[str | None] = mapped_column(String(255))
# Global status
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_platform_admin: Mapped[bool] = mapped_column(Boolean, default=False)
# Verification status
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
# Relationships
tenant_users: Mapped[list["User"]] = relationship(back_populates="identity")
class User(Base):
"""
Tenant Identity (Member ID).
Represents a person's role and profile within a specific company.
"""
__tablename__ = "users"
# Note: Unique constraints for (tenant_id, username), (tenant_id, email) and (tenant_id, primary_mobile)
# are handled via partial unique indexes in migration to allow NULL values
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Link to global identity
identity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("identities.id"), index=True)
# Tenant context
tenant_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id"))
# Tenant-specific profile
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(500))
title: Mapped[str | None] = mapped_column(String(100))
role: Mapped[str] = mapped_column(
Enum("platform_admin", "org_admin", "agent_admin", "member", name="user_role_enum"),
default="member",
nullable=False,
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
registration_source: Mapped[str | None] = mapped_column(String(50), default="web")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
# Usage quotas (set by admin, defaults from tenant)
quota_message_limit: Mapped[int] = mapped_column(Integer, default=50)
quota_message_period: Mapped[str] = mapped_column(String(20), default="permanent") # permanent|daily|weekly|monthly
quota_messages_used: Mapped[int] = mapped_column(Integer, default=0)
quota_period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
quota_max_agents: Mapped[int] = mapped_column(Integer, default=2)
quota_agent_ttl_hours: Mapped[int] = mapped_column(Integer, default=48)
# Relationships
# lazy="selectin" is required because association_proxy fields (email, username,
# password_hash, email_verified, primary_mobile) delegate to this relationship.
# Without eager loading, any proxy access in an async context triggers a synchronous
# IO call inside a greenlet, raising sqlalchemy.exc.MissingGreenlet.
identity: Mapped["Identity"] = relationship(back_populates="tenant_users", lazy="selectin")
# Association proxies for backward compatibility
email = association_proxy("identity", "email")
username = association_proxy("identity", "username")
password_hash = association_proxy("identity", "password_hash")
email_verified = association_proxy("identity", "email_verified")
primary_mobile = association_proxy("identity", "phone")
created_agents: Mapped[list["Agent"]] = relationship(back_populates="creator", foreign_keys="Agent.creator_id")
# Forward reference for Agent used in User relationship
from app.models.agent import Agent # noqa: E402, F401
from app.models.org import OrgMember # noqa: E402, F401