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}"))
|
||||
Reference in New Issue
Block a user