Files
bambuddy/backend/app/models/user.py
T
maziggy 18d534c945 feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account
  (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing
  Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub /
  email+password); password defaults. Paste-flow PKCE because Orca's
  Supabase project only allowlists localhost redirect_to — open feature
  request at OrcaSlicer/OrcaSlicer#14028.

  Surfaces:
  - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same
    rich layout (search + 5 filter dropdowns + 3-column grouped grid +
    read-only detail modal)
  - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud >
    standard); separate status banner per cloud; metadata-aware pre-pick
    scores Orca filaments above local (Orca's sync_pull returns full
    content inline so filament_type / filament_colour come for free, no
    per-setting fetch rate-limit dance)
  - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed
    orca_<UUID> to match local_/builtin_); generic Bambu filament-ID
    derivation from parsed material (printer firmware can't grok Orca
    UUIDs); slot mapping persists preset_source='orca_cloud'
  - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the
    cloud preset list via Promise.allSettled (OrcaProfileMeta is
    structurally identical to SlicerSetting)

  Backend:
  - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange /
    single-use refresh rotation / get_user_info / list_profiles via the
    bare /sync/pull bootstrap path
  - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish,
    auth/password, status, logout, profiles, profiles/{id}); router-level
    _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers
    (SpoolBuddy kiosk) properly resolve their owner User; just-in-time
    refresh with atomic persist-before-API-call
  - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu
    Cloud fetcher (status vocabulary, 5min cache, permission shortcut);
    _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains
    orca_cloud + orca_cloud_status
  - services/preset_resolver.py: PresetRef.source extended with
    "orca_cloud"; _resolve_orca_cloud walks list + filters
  - 8 columns on users table for tokens (5 persistent) + transient PKCE
    handshake state with 10-min TTL (3); dialect-branched DATETIME /
    TIMESTAMP; auth-disabled mode falls back to Settings table
  - orca_cloud:auth permission folded into can_access_cloud API-key scope
    (same trust dimension)
2026-06-04 15:50:44 +02:00

136 lines
5.9 KiB
Python

from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
if TYPE_CHECKING:
from backend.app.models.group import Group
from backend.app.models.user_email_pref import UserEmailPreference
class User(Base):
"""User model for authentication and authorization.
Users can belong to multiple groups, and their permissions are additive
across all groups. The legacy 'role' field is kept for backward compatibility
but is_admin property now also considers group membership.
"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(
String(20), default="user"
) # "admin" or "user" (legacy, kept for backward compat)
auth_source: Mapped[str] = mapped_column(String(20), default="local") # "local", "ldap", or "oidc"
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
# Set whenever the local password is changed/reset — used to invalidate JWTs
# issued before the change (M-R7-B). NULL means no password change recorded yet.
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
# "global" or "china"; NULL treated as "global" for legacy rows.
cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
# Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE
# with short-lived access tokens (1h) and rotating single-use refresh tokens,
# so we store the refresh token + expiry alongside the access token.
orca_cloud_token: Mapped[str | None] = mapped_column(String(2000), nullable=True, default=None)
orca_cloud_refresh_token: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
orca_cloud_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
orca_cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
orca_cloud_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
# Transient PKCE state held between /orca-cloud/auth/start and /orca-cloud/auth/finish.
# Cleared on successful finish; expires after 10 minutes if the user abandons the flow.
orca_cloud_pending_verifier: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
orca_cloud_pending_state: Mapped[str | None] = mapped_column(String(32), nullable=True, default=None)
orca_cloud_pending_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
# Relationship to groups through association table
groups: Mapped[list[Group]] = relationship(
"Group",
secondary="user_groups",
back_populates="users",
lazy="selectin",
)
# Relationship to email notification preferences
email_preferences: Mapped[UserEmailPreference | None] = relationship(
"UserEmailPreference",
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
lazy="select",
)
@property
def is_admin(self) -> bool:
"""Check if user is an admin.
Returns True if:
- User has legacy role='admin', OR
- User belongs to the Administrators group
"""
if self.role == "admin":
return True
return any(g.name == "Administrators" for g in self.groups)
def get_permissions(self) -> set[str]:
"""Get all permissions from all groups the user belongs to.
Returns a set of permission strings. Permissions are additive across groups.
"""
permissions: set[str] = set()
for group in self.groups:
if group.permissions:
permissions.update(group.permissions)
return permissions
def has_permission(self, permission: str) -> bool:
"""Check if user has a specific permission.
Admins have all permissions. For other users, checks if the permission
exists in any of their groups.
"""
if self.is_admin:
return True
return permission in self.get_permissions()
def has_all_permissions(self, *permissions: str) -> bool:
"""Check if user has ALL specified permissions.
Admins have all permissions. For other users, checks if all permissions
exist in their combined group permissions.
"""
if self.is_admin:
return True
user_permissions = self.get_permissions()
return all(p in user_permissions for p in permissions)
def has_any_permission(self, *permissions: str) -> bool:
"""Check if user has ANY of the specified permissions.
Admins have all permissions. For other users, checks if at least one
permission exists in their combined group permissions.
"""
if self.is_admin:
return True
user_permissions = self.get_permissions()
return any(p in user_permissions for p in permissions)
def __repr__(self) -> str:
return f"<User {self.username}>"