- 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>
239 lines
11 KiB
Python
239 lines
11 KiB
Python
"""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}"))
|