ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده

- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت
  d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین
  می‌کند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد.
- ثبت‌نام عمومی حذف شد (POST /auth/signup). حساب‌ها فقط با POST /admin/users
  (فقط مالک، با رمز موقت یک‌بارمصرف) یا با پذیرش لینک دعوت ساخته می‌شوند.
  در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوت‌شده رمز تعیین می‌کند؛ اگر
  وجود داشته باشد لینک هرگز جای reset رمز را نمی‌گیرد.
- ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو می‌شود.
- تنزل یا حذف مدیر، سازمان‌هایش را به مالک سامانه منتقل می‌کند (مالکیت + عضویت،
  با audit). حساب مالک از API تغییرناپذیر است؛ جابه‌جایی صندلی فقط با
  POST /admin/transfer-ownership و در یک تراکنش انجام می‌شود.
- افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی.
- فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup،
  گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ.
- PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان
  رفتار کند (پیش‌تر حذف کاربر، عضویت‌هایش را یتیم می‌گذاشت).
- تست: ۳۰۰ تست بک‌اند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست
  فرانت‌اند؛ ruff، mypy، tsc، eslint و build تمیز.
This commit is contained in:
mjalmoudy94
2026-10-13 21:58:41 +03:30
parent 6937b88ca7
commit 8eb631aedf
41 changed files with 2952 additions and 454 deletions
+41 -1
View File
@@ -17,6 +17,31 @@ class Role(StrEnum):
VIEWER = "viewer"
class PlatformRole(StrEnum):
"""Platform-wide role — a different axis from the in-organization ``Role``.
The platform has exactly three levels, and exactly one ``OWNER`` account
exists (enforced by a partial unique index on ``users.platform_role``):
- ``OWNER`` — the single owner of the whole system. The only account that
may enter the admin console, create or demote managers, and transfer
ownership.
- ``MANAGER`` — an account created by the owner. A manager may create
their own organizations (becoming their organization owner) and join
other managers' organizations by invitation.
- ``USER`` — a plain account. Has no platform privileges: cannot create
organizations, only works inside organizations it was invited to.
"""
OWNER = "owner"
MANAGER = "manager"
USER = "user"
#: Roles that may create a new organization. Everyone else must be invited.
PLATFORM_ROLES_CAN_CREATE_ORG = frozenset({PlatformRole.OWNER, PlatformRole.MANAGER})
#: Higher rank implies every permission of the lower ranks.
ROLE_RANK: dict[Role, int] = {
Role.VIEWER: 10,
@@ -88,11 +113,26 @@ class User:
name: str = ""
id: str = field(default_factory=new_id)
avatar_url: str | None = None
is_platform_owner: bool = False
#: Platform-wide role. ``is_platform_owner`` / ``is_manager`` are derived
#: from it; only one ``OWNER`` may exist (see the partial unique index on
#: ``users.platform_role``).
platform_role: PlatformRole = PlatformRole.USER
is_active: bool = True
created_at: datetime = field(default_factory=utcnow)
updated_at: datetime = field(default_factory=utcnow)
@property
def is_platform_owner(self) -> bool:
return self.platform_role == PlatformRole.OWNER
@property
def is_manager(self) -> bool:
return self.platform_role == PlatformRole.MANAGER
@property
def can_create_organization(self) -> bool:
return self.platform_role in PLATFORM_ROLES_CAN_CREATE_ORG
@property
def display_name(self) -> str:
return self.name.strip() or self.email.split("@")[0]