feat: Evotor user lifecycle, RBAC, admin panel
- Receive Evotor webhooks: POST /user/create, /user/verify, /user/token
- Create users in pending status; match to existing users by email/phone
- Send invite link via Celery notification task; user sets password at /invite
- Abstract EmailProvider/SMSProvider with ConsoleEmailProvider default
- Role-based access control: role enum on users + roles/permissions tables
- Admin panel: /admin/users (list, filter, search, paginate), user detail card
with activate/suspend/reset-password/send-invite/edit/delete actions
- Admin roles management: /admin/roles with per-role permission assignment
- Extend user profile card: role, status, Evotor ID, email confirmation badge
- Auth routes: register, login, logout, confirm-email, forgot/reset password
- Alembic migrations 0002 (full schema + new fields) and 0003 (RBAC + seeds)
- Port Pico CSS + Bootstrap Icons UI from Node.js commit (854c912)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
238
web/migrations/versions/0002_users_and_connections.py
Normal file
238
web/migrations/versions/0002_users_and_connections.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""users and connections schema
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-04-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: Union[str, None] = "0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── users ────────────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
password_hash VARCHAR(255) NULL,
|
||||
is_email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
email_confirm_token VARCHAR(255) NULL,
|
||||
password_reset_token VARCHAR(255) NULL,
|
||||
password_reset_expires DATETIME NULL,
|
||||
role ENUM('system','admin','user') NOT NULL DEFAULT 'user',
|
||||
status ENUM('pending','active','suspended') NOT NULL DEFAULT 'pending',
|
||||
evotor_user_id VARCHAR(255) NULL,
|
||||
evotor_meta JSON NULL,
|
||||
invite_token VARCHAR(255) NULL,
|
||||
invite_expires DATETIME NULL,
|
||||
phone_otp VARCHAR(10) NULL,
|
||||
phone_otp_expires DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_users_email UNIQUE (email),
|
||||
CONSTRAINT ix_users_phone UNIQUE (phone),
|
||||
CONSTRAINT ix_users_evotor_user_id UNIQUE (evotor_user_id)
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# Add new columns if migrating from the Node.js schema (which lacked them)
|
||||
for col_sql in [
|
||||
"ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(255) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role ENUM('system','admin','user') NOT NULL DEFAULT 'user'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('pending','active','suspended') NOT NULL DEFAULT 'pending'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS evotor_user_id VARCHAR(255) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS evotor_meta JSON NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_token VARCHAR(255) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_expires DATETIME NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_otp VARCHAR(10) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_otp_expires DATETIME NULL",
|
||||
]:
|
||||
try:
|
||||
conn.execute(sa.text(col_sql))
|
||||
except Exception:
|
||||
pass # column/constraint already exists
|
||||
|
||||
# Add unique index on evotor_user_id if missing
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE users ADD CONSTRAINT ix_users_evotor_user_id UNIQUE (evotor_user_id)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add role/status indexes if missing
|
||||
for idx_sql in [
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_role ON users (role)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_status ON users (status)",
|
||||
]:
|
||||
try:
|
||||
conn.execute(sa.text(idx_sql))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── evotor_connections ───────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS evotor_connections (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NULL,
|
||||
evotor_user_id VARCHAR(255) NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
api_token VARCHAR(255) NULL,
|
||||
store_id VARCHAR(255) NULL,
|
||||
store_name VARCHAR(255) NULL,
|
||||
refresh_token TEXT NULL,
|
||||
token_expires_at DATETIME NULL,
|
||||
is_online BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_checked_at DATETIME NULL,
|
||||
connected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_evotor_connections_user_id UNIQUE (user_id),
|
||||
CONSTRAINT ix_evotor_connections_evotor_user_id UNIQUE (evotor_user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE evotor_connections ADD COLUMN IF NOT EXISTS api_token VARCHAR(255) NULL"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── vk_connections ───────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS vk_connections (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
vk_user_id VARCHAR(50) NULL,
|
||||
first_name VARCHAR(255) NULL,
|
||||
last_name VARCHAR(255) NULL,
|
||||
is_online BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_checked_at DATETIME NULL,
|
||||
connected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_vk_connections_user_id UNIQUE (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# ── sync_configs ─────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS sync_configs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
confirmed_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_sync_configs_user_id UNIQUE (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# ── sync_filters ─────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS sync_filters (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
sync_config_id INT NOT NULL,
|
||||
entity_type VARCHAR(20) NOT NULL,
|
||||
entity_id VARCHAR(255) NOT NULL,
|
||||
entity_name VARCHAR(255) NULL,
|
||||
filter_mode VARCHAR(10) NOT NULL,
|
||||
parent_entity_id VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_sync_filters_config_type_entity UNIQUE (sync_config_id, entity_type, entity_id),
|
||||
FOREIGN KEY (sync_config_id) REFERENCES sync_configs(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# ── cached_stores ────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS cached_stores (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
evotor_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address VARCHAR(500) NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
CONSTRAINT uq_cached_stores_user_evotor UNIQUE (user_id, evotor_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
try:
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_cached_stores_user_id ON cached_stores (user_id)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── cached_groups ────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS cached_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
evotor_id VARCHAR(255) NOT NULL,
|
||||
store_evotor_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
CONSTRAINT uq_cached_groups_user_evotor UNIQUE (user_id, evotor_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_cached_groups_user_store ON cached_groups (user_id, store_evotor_id)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── cached_products ──────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS cached_products (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
evotor_id VARCHAR(255) NOT NULL,
|
||||
store_evotor_id VARCHAR(255) NOT NULL,
|
||||
group_evotor_id VARCHAR(255) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
price DECIMAL(12,2) NULL,
|
||||
quantity DECIMAL(12,3) NULL,
|
||||
measure_name VARCHAR(20) NULL,
|
||||
article_number VARCHAR(100) NULL,
|
||||
allow_to_sell BOOLEAN NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
synced_at DATETIME NULL,
|
||||
CONSTRAINT uq_cached_products_user_evotor UNIQUE (user_id, evotor_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_cached_products_user_store_group "
|
||||
"ON cached_products (user_id, store_evotor_id, group_evotor_id)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in [
|
||||
"cached_products", "cached_groups", "cached_stores",
|
||||
"sync_filters", "sync_configs",
|
||||
"vk_connections", "evotor_connections",
|
||||
"users",
|
||||
]:
|
||||
conn.execute(sa.text(f"DROP TABLE IF EXISTS {table}"))
|
||||
105
web/migrations/versions/0003_rbac_tables.py
Normal file
105
web/migrations/versions/0003_rbac_tables.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""RBAC tables with default roles and permissions
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-04-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: Union[str, None] = "0002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
DEFAULT_ROLES = [
|
||||
("system", "Системный администратор — полный доступ"),
|
||||
("admin", "Администратор — управление пользователями"),
|
||||
("user", "Обычный пользователь"),
|
||||
]
|
||||
|
||||
DEFAULT_PERMISSIONS = [
|
||||
("admin.users.view", "Просмотр списка пользователей"),
|
||||
("admin.users.edit", "Редактирование пользователей"),
|
||||
("admin.users.delete", "Удаление пользователей"),
|
||||
("admin.roles.manage", "Управление ролями и правами"),
|
||||
]
|
||||
|
||||
# system gets all permissions; admin gets view+edit
|
||||
ROLE_PERMISSION_MAP = {
|
||||
"system": ["admin.users.view", "admin.users.edit", "admin.users.delete", "admin.roles.manage"],
|
||||
"admin": ["admin.users.view", "admin.users.edit"],
|
||||
"user": [],
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
CONSTRAINT uq_roles_name UNIQUE (name)
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
CONSTRAINT uq_permissions_name UNIQUE (name)
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id INT NOT NULL,
|
||||
permission_id INT NOT NULL,
|
||||
PRIMARY KEY (role_id, permission_id),
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id INT NOT NULL,
|
||||
role_id INT NOT NULL,
|
||||
PRIMARY KEY (user_id, role_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# Seed default roles
|
||||
for name, description in DEFAULT_ROLES:
|
||||
conn.execute(sa.text(
|
||||
"INSERT IGNORE INTO roles (name, description) VALUES (:name, :desc)"
|
||||
), {"name": name, "desc": description})
|
||||
|
||||
# Seed default permissions
|
||||
for name, description in DEFAULT_PERMISSIONS:
|
||||
conn.execute(sa.text(
|
||||
"INSERT IGNORE INTO permissions (name, description) VALUES (:name, :desc)"
|
||||
), {"name": name, "desc": description})
|
||||
|
||||
# Seed role_permissions
|
||||
for role_name, perm_names in ROLE_PERMISSION_MAP.items():
|
||||
for perm_name in perm_names:
|
||||
conn.execute(sa.text("""
|
||||
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id FROM roles r, permissions p
|
||||
WHERE r.name = :role AND p.name = :perm
|
||||
"""), {"role": role_name, "perm": perm_name})
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in ["user_roles", "role_permissions", "permissions", "roles"]:
|
||||
conn.execute(sa.text(f"DROP TABLE IF EXISTS {table}"))
|
||||
Reference in New Issue
Block a user