ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- 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:
@@ -0,0 +1,87 @@
|
||||
"""Single platform owner; users gain a platform role
|
||||
|
||||
The old schema had ``users.is_platform_owner`` as a plain boolean with no
|
||||
uniqueness guard, so nothing stopped a second (or tenth) platform owner from
|
||||
appearing. This migration replaces the boolean with ``platform_role``
|
||||
(``owner`` | ``manager`` | ``user``) and makes "exactly one owner" a *database*
|
||||
invariant through a partial unique index — a second ``owner`` INSERT now fails
|
||||
even if some future code path forgets to check.
|
||||
|
||||
Backfill rules:
|
||||
|
||||
- any row that was ``is_platform_owner = true`` becomes ``owner``;
|
||||
- if a legacy database somehow had several owners, only the earliest (by
|
||||
``created_at``) keeps the role and the rest are demoted to ``manager``;
|
||||
- everything else becomes ``user``.
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c2d3e4f5b6a7
|
||||
Create Date: 2026-10-13 18:20:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "d4e5f6a7b8c9"
|
||||
down_revision: str | None = "c2d3e4f5b6a7"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_OWNER_WHERE = sa.text("platform_role = 'owner'")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"platform_role",
|
||||
sa.String(length=16),
|
||||
nullable=False,
|
||||
server_default="user",
|
||||
),
|
||||
)
|
||||
# Defensive backfill: keep only the earliest owner, demote any others.
|
||||
# Written with the boolean literal ``FALSE`` because both SQLite (>=3.23)
|
||||
# and PostgreSQL accept it, while ``0`` would be a type error on Postgres.
|
||||
op.execute(
|
||||
"UPDATE users SET is_platform_owner = FALSE "
|
||||
"WHERE is_platform_owner AND id NOT IN "
|
||||
"(SELECT id FROM users WHERE is_platform_owner ORDER BY created_at LIMIT 1)"
|
||||
)
|
||||
op.execute("UPDATE users SET platform_role = 'owner' WHERE is_platform_owner")
|
||||
op.create_index(
|
||||
"ix_user_platform_role",
|
||||
"users",
|
||||
["platform_role"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_single_platform_owner",
|
||||
"users",
|
||||
["platform_role"],
|
||||
unique=True,
|
||||
sqlite_where=_OWNER_WHERE,
|
||||
postgresql_where=_OWNER_WHERE,
|
||||
)
|
||||
op.drop_column("users", "is_platform_owner")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"is_platform_owner",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.execute("UPDATE users SET is_platform_owner = TRUE WHERE platform_role = 'owner'")
|
||||
op.drop_index("uq_single_platform_owner", table_name="users")
|
||||
op.drop_index("ix_user_platform_role", table_name="users")
|
||||
op.drop_column("users", "platform_role")
|
||||
Reference in New Issue
Block a user