Add onboarding_dismissed and branding columns (logo_data, logo_content_type, company_display_name) to users and teams models. Create SessionSupportingData model for attaching text snippets and screenshots to sessions. Add Pydantic schemas for onboarding status, branding responses, and supporting data CRUD. Update SessionExport to accept pdf format. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
128 lines
5.3 KiB
Python
128 lines
5.3 KiB
Python
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, TYPE_CHECKING
|
|
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
from app.core.database import Base
|
|
|
|
if TYPE_CHECKING:
|
|
from app.models.team import Team
|
|
from app.models.account import Account
|
|
from app.models.tree import Tree
|
|
from app.models.session import Session
|
|
from app.models.folder import UserFolder
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
__table_args__ = (
|
|
CheckConstraint(
|
|
"role IN ('engineer', 'viewer')",
|
|
name='ck_users_role_enum'
|
|
),
|
|
CheckConstraint(
|
|
"account_role IN ('owner', 'admin', 'engineer', 'viewer')",
|
|
name='ck_users_account_role_enum'
|
|
),
|
|
)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
primary_key=True,
|
|
default=uuid.uuid4
|
|
)
|
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
|
|
is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
is_team_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
|
is_service_account: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
|
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
|
|
|
# Account-based multi-tenancy (new)
|
|
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("accounts.id", ondelete="RESTRICT"),
|
|
nullable=True,
|
|
index=True
|
|
)
|
|
account_role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
|
|
|
|
# Legacy team columns (kept for PR A coexistence)
|
|
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("teams.id"),
|
|
nullable=True
|
|
)
|
|
invite_code_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("invite_codes.id"),
|
|
nullable=True
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(timezone.utc)
|
|
)
|
|
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
|
|
# Profile fields
|
|
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
|
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC", server_default="UTC")
|
|
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
|
|
# Onboarding
|
|
onboarding_dismissed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false")
|
|
|
|
# Branding (solo pros without a team)
|
|
logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
|
company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
|
|
email_verified_at: Mapped[Optional[datetime]] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# AI billing cycle anchor (for quota reset calculation)
|
|
ai_billing_cycle_anchor_at: Mapped[Optional[datetime]] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Soft delete
|
|
deleted_at: Mapped[Optional[datetime]] = mapped_column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
index=True
|
|
)
|
|
deleted_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id"),
|
|
nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id], back_populates="users")
|
|
owned_account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys="[Account.owner_id]", back_populates="owner", uselist=False)
|
|
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="users")
|
|
trees: Mapped[list["Tree"]] = relationship("Tree", foreign_keys="[Tree.author_id]", back_populates="author")
|
|
sessions: Mapped[list["Session"]] = relationship("Session", foreign_keys="[Session.user_id]", back_populates="user")
|
|
folders: Mapped[list["UserFolder"]] = relationship("UserFolder", back_populates="user")
|
|
|
|
@property
|
|
def is_admin(self) -> bool:
|
|
"""Returns True if user is a super admin (system-wide access)."""
|
|
return self.is_super_admin
|
|
|
|
@property
|
|
def is_account_owner(self) -> bool:
|
|
"""Returns True if user owns their account."""
|
|
return self.account_role == "owner"
|
|
|
|
@property
|
|
def can_manage_account(self) -> bool:
|
|
"""Returns True if user can manage their account (owner, admin, or super admin)."""
|
|
return self.is_super_admin or self.account_role in ("owner", "admin")
|