user.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. from __future__ import annotations
  2. from datetime import datetime
  3. from typing import TYPE_CHECKING
  4. from sqlalchemy import DateTime, String, func
  5. from sqlalchemy.orm import Mapped, mapped_column, relationship
  6. from backend.app.core.database import Base
  7. if TYPE_CHECKING:
  8. from backend.app.models.group import Group
  9. from backend.app.models.user_email_pref import UserEmailPreference
  10. class User(Base):
  11. """User model for authentication and authorization.
  12. Users can belong to multiple groups, and their permissions are additive
  13. across all groups. The legacy 'role' field is kept for backward compatibility
  14. but is_admin property now also considers group membership.
  15. """
  16. __tablename__ = "users"
  17. id: Mapped[int] = mapped_column(primary_key=True)
  18. username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
  19. email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
  20. password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
  21. role: Mapped[str] = mapped_column(
  22. String(20), default="user"
  23. ) # "admin" or "user" (legacy, kept for backward compat)
  24. auth_source: Mapped[str] = mapped_column(String(20), default="local") # "local", "ldap", or "oidc"
  25. is_active: Mapped[bool] = mapped_column(default=True)
  26. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  27. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  28. # Set whenever the local password is changed/reset — used to invalidate JWTs
  29. # issued before the change (M-R7-B). NULL means no password change recorded yet.
  30. password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
  31. # Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
  32. cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
  33. cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
  34. # "global" or "china"; NULL treated as "global" for legacy rows.
  35. cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
  36. # Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE
  37. # with short-lived access tokens (1h) and rotating single-use refresh tokens,
  38. # so we store the refresh token + expiry alongside the access token.
  39. orca_cloud_token: Mapped[str | None] = mapped_column(String(2000), nullable=True, default=None)
  40. orca_cloud_refresh_token: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
  41. orca_cloud_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
  42. orca_cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
  43. orca_cloud_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
  44. # Transient PKCE state held between /orca-cloud/auth/start and /orca-cloud/auth/finish.
  45. # Cleared on successful finish; expires after 10 minutes if the user abandons the flow.
  46. orca_cloud_pending_verifier: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
  47. orca_cloud_pending_state: Mapped[str | None] = mapped_column(String(32), nullable=True, default=None)
  48. orca_cloud_pending_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
  49. # Onboarding tour state. See docs/onboarding-tour-plan.md Appendix B for the
  50. # state model — null means the welcome modal has not been shown yet.
  51. onboarding_status: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
  52. onboarding_snoozed_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
  53. # Relationship to groups through association table
  54. groups: Mapped[list[Group]] = relationship(
  55. "Group",
  56. secondary="user_groups",
  57. back_populates="users",
  58. lazy="selectin",
  59. )
  60. # Relationship to email notification preferences
  61. email_preferences: Mapped[UserEmailPreference | None] = relationship(
  62. "UserEmailPreference",
  63. back_populates="user",
  64. uselist=False,
  65. cascade="all, delete-orphan",
  66. lazy="select",
  67. )
  68. @property
  69. def is_admin(self) -> bool:
  70. """Check if user is an admin.
  71. Returns True if:
  72. - User has legacy role='admin', OR
  73. - User belongs to the Administrators group
  74. """
  75. if self.role == "admin":
  76. return True
  77. return any(g.name == "Administrators" for g in self.groups)
  78. def get_permissions(self) -> set[str]:
  79. """Get all permissions from all groups the user belongs to.
  80. Returns a set of permission strings. Permissions are additive across groups.
  81. """
  82. permissions: set[str] = set()
  83. for group in self.groups:
  84. if group.permissions:
  85. permissions.update(group.permissions)
  86. return permissions
  87. def has_permission(self, permission: str) -> bool:
  88. """Check if user has a specific permission.
  89. Admins have all permissions. For other users, checks if the permission
  90. exists in any of their groups.
  91. """
  92. if self.is_admin:
  93. return True
  94. return permission in self.get_permissions()
  95. def has_all_permissions(self, *permissions: str) -> bool:
  96. """Check if user has ALL specified permissions.
  97. Admins have all permissions. For other users, checks if all permissions
  98. exist in their combined group permissions.
  99. """
  100. if self.is_admin:
  101. return True
  102. user_permissions = self.get_permissions()
  103. return all(p in user_permissions for p in permissions)
  104. def has_any_permission(self, *permissions: str) -> bool:
  105. """Check if user has ANY of the specified permissions.
  106. Admins have all permissions. For other users, checks if at least one
  107. permission exists in their combined group permissions.
  108. """
  109. if self.is_admin:
  110. return True
  111. user_permissions = self.get_permissions()
  112. return any(p in user_permissions for p in permissions)
  113. def __repr__(self) -> str:
  114. return f"<User {self.username}>"