From 5ead89e0cf1db9fa88ac2edc18d40a2f4c59402d Mon Sep 17 00:00:00 2001 From: mguschin Date: Tue, 28 Apr 2026 12:01:25 +0300 Subject: [PATCH] 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 --- docker-compose.yml | 2 +- web/auth/__init__.py | 0 web/auth/password.py | 11 + web/auth/rbac.py | 35 ++ web/auth/session.py | 25 + web/config.py | 5 + web/main.py | 63 ++- .../versions/0002_users_and_connections.py | 238 ++++++++++ web/migrations/versions/0003_rbac_tables.py | 105 +++++ web/models/__init__.py | 11 + web/models/connections.py | 136 ++++++ web/models/rbac.py | 34 ++ web/models/user.py | 50 ++ web/notifications/__init__.py | 0 web/notifications/base.py | 11 + web/notifications/console.py | 28 ++ web/notifications/registry.py | 17 + web/notifications/tasks.py | 12 + web/routes/__init__.py | 0 web/routes/admin.py | 271 +++++++++++ web/routes/auth.py | 170 +++++++ web/routes/evotor_webhooks.py | 269 +++++++++++ web/routes/invite.py | 99 ++++ web/routes/profile.py | 143 ++++++ web/routes/reset.py | 101 ++++ web/static/style.css | 431 ++++++++++++++++++ web/tasks/celery_app.py | 1 + web/templates/admin/roles.html | 40 ++ web/templates/admin/user_detail.html | 147 ++++++ web/templates/admin/users.html | 118 +++++ web/templates/base.html | 106 +++++ web/templates/confirm_email.html | 16 + web/templates/email_confirmed.html | 17 + web/templates/forgot_password.html | 24 + web/templates/invite.html | 44 ++ web/templates/login.html | 27 ++ web/templates/message.html | 18 + web/templates/profile_change_password.html | 31 ++ web/templates/profile_delete.html | 31 ++ web/templates/profile_edit.html | 43 ++ web/templates/profile_view.html | 86 ++++ web/templates/register.html | 44 ++ web/templates/reset_password.html | 23 + web/templates_env.py | 21 + 44 files changed, 3101 insertions(+), 3 deletions(-) create mode 100644 web/auth/__init__.py create mode 100644 web/auth/password.py create mode 100644 web/auth/rbac.py create mode 100644 web/auth/session.py create mode 100644 web/migrations/versions/0002_users_and_connections.py create mode 100644 web/migrations/versions/0003_rbac_tables.py create mode 100644 web/models/connections.py create mode 100644 web/models/rbac.py create mode 100644 web/models/user.py create mode 100644 web/notifications/__init__.py create mode 100644 web/notifications/base.py create mode 100644 web/notifications/console.py create mode 100644 web/notifications/registry.py create mode 100644 web/notifications/tasks.py create mode 100644 web/routes/__init__.py create mode 100644 web/routes/admin.py create mode 100644 web/routes/auth.py create mode 100644 web/routes/evotor_webhooks.py create mode 100644 web/routes/invite.py create mode 100644 web/routes/profile.py create mode 100644 web/routes/reset.py create mode 100644 web/static/style.css create mode 100644 web/templates/admin/roles.html create mode 100644 web/templates/admin/user_detail.html create mode 100644 web/templates/admin/users.html create mode 100644 web/templates/base.html create mode 100644 web/templates/confirm_email.html create mode 100644 web/templates/email_confirmed.html create mode 100644 web/templates/forgot_password.html create mode 100644 web/templates/invite.html create mode 100644 web/templates/login.html create mode 100644 web/templates/message.html create mode 100644 web/templates/profile_change_password.html create mode 100644 web/templates/profile_delete.html create mode 100644 web/templates/profile_edit.html create mode 100644 web/templates/profile_view.html create mode 100644 web/templates/register.html create mode 100644 web/templates/reset_password.html create mode 100644 web/templates_env.py diff --git a/docker-compose.yml b/docker-compose.yml index 08d0d8c..82116f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,7 +73,7 @@ services: condition: service_healthy db: condition: service_healthy - command: celery -A web.tasks.celery_app worker --loglevel=info --concurrency=2 --queues=default,sync,health + command: celery -A web.tasks.celery_app worker --loglevel=info --concurrency=2 --queues=default,sync,health,notifications beat: build: diff --git a/web/auth/__init__.py b/web/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/auth/password.py b/web/auth/password.py new file mode 100644 index 0000000..6f57ba0 --- /dev/null +++ b/web/auth/password.py @@ -0,0 +1,11 @@ +from passlib.context import CryptContext + +_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(plain: str) -> str: + return _ctx.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + return _ctx.verify(plain, hashed) diff --git a/web/auth/rbac.py b/web/auth/rbac.py new file mode 100644 index 0000000..69c9815 --- /dev/null +++ b/web/auth/rbac.py @@ -0,0 +1,35 @@ +from fastapi import Depends, HTTPException +from sqlalchemy.orm import Session +from starlette.requests import Request + +from web.auth.session import get_current_user +from web.database import get_db +from web.models.rbac import Permission, UserRole, role_permissions +from web.models.user import User, UserRoleEnum + + +def require_role(*roles: str): + def dep(request: Request, db: Session = Depends(get_db)) -> User: + user = get_current_user(request, db) + if user.role.value not in roles: + raise HTTPException(status_code=403, detail="Недостаточно прав") + return user + return Depends(dep) + + +def require_permission(permission_name: str): + def dep(request: Request, db: Session = Depends(get_db)) -> User: + user = get_current_user(request, db) + if user.role == UserRoleEnum.system: + return user + has = ( + db.query(Permission) + .join(role_permissions, Permission.id == role_permissions.c.permission_id) + .join(UserRole, UserRole.role_id == role_permissions.c.role_id) + .filter(UserRole.user_id == user.id, Permission.name == permission_name) + .first() + ) + if not has: + raise HTTPException(status_code=403, detail="Недостаточно прав") + return user + return Depends(dep) diff --git a/web/auth/session.py b/web/auth/session.py new file mode 100644 index 0000000..67c126f --- /dev/null +++ b/web/auth/session.py @@ -0,0 +1,25 @@ +from fastapi import HTTPException +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session +from starlette.requests import Request + +from web.models.user import User, UserStatusEnum + + +def get_session_user_id(request: Request) -> int | None: + return request.session.get("user_id") + + +def get_current_user(request: Request, db: Session) -> User: + user_id = get_session_user_id(request) + if not user_id: + raise HTTPException(status_code=307, headers={"Location": "/login"}) + user = db.get(User, user_id) + if not user or user.status == UserStatusEnum.suspended: + request.session.clear() + raise HTTPException(status_code=307, headers={"Location": "/login"}) + return user + + +def login_redirect() -> RedirectResponse: + return RedirectResponse("/login", status_code=303) diff --git a/web/config.py b/web/config.py index b5775fa..877d235 100644 --- a/web/config.py +++ b/web/config.py @@ -16,6 +16,11 @@ class Settings(BaseSettings): VK_API_VERSION: str = "5.199" CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600 + INVITE_EXPIRE_HOURS: int = 48 + EMAIL_PROVIDER: str = "console" + SMS_PROVIDER: str = "console" + SYSTEM_USER_EMAIL: str = "" + SYSTEM_USER_PASSWORD: str = "" FLOWER_USER: str = "admin" FLOWER_PASSWORD: str = "changeme" diff --git a/web/main.py b/web/main.py index b65599e..fb1e02f 100644 --- a/web/main.py +++ b/web/main.py @@ -1,6 +1,9 @@ import logging -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware try: from pythonjsonlogger import jsonlogger @@ -11,9 +14,65 @@ except ImportError: logging.basicConfig(level=logging.INFO) logging.root.setLevel(logging.INFO) -app = FastAPI(title="EvoSync") +from web.config import settings # noqa: E402 — after logging setup +from web.templates_env import templates # noqa: E402 + +app = FastAPI(title="ЭвоСинк") + +app.add_middleware( + SessionMiddleware, + secret_key=settings.SECRET_KEY, + max_age=86400 * 30, + https_only=False, +) + +app.mount("/static", StaticFiles(directory="web/static"), name="static") + +# ── Routers ─────────────────────────────────────────────────────────────────── +from web.routes.auth import router as auth_router # noqa: E402 +from web.routes.reset import router as reset_router # noqa: E402 +from web.routes.invite import router as invite_router # noqa: E402 +from web.routes.profile import router as profile_router # noqa: E402 +from web.routes.evotor_webhooks import router as evotor_webhooks_router # noqa: E402 +from web.routes.admin import router as admin_router # noqa: E402 + +app.include_router(auth_router) +app.include_router(reset_router) +app.include_router(invite_router) +app.include_router(profile_router) +app.include_router(evotor_webhooks_router) +app.include_router(admin_router) +# ── Health ──────────────────────────────────────────────────────────────────── @app.get("/health") async def health(): return {"status": "ok"} + + +# ── Root redirect ───────────────────────────────────────────────────────────── +@app.get("/") +async def root(request: Request): + from fastapi.responses import RedirectResponse + user_id = request.session.get("user_id") + if user_id: + return RedirectResponse("/profile", 303) + return RedirectResponse("/login", 303) + + +# ── 403 handler ─────────────────────────────────────────────────────────────── +from fastapi import HTTPException # noqa: E402 +from fastapi.exception_handlers import http_exception_handler # noqa: E402 + + +@app.exception_handler(403) +async def forbidden_handler(request: Request, exc: HTTPException) -> HTMLResponse: + return templates.TemplateResponse("message.html", { + "request": request, + "user": None, + "title": "Нет доступа", + "message": "У вас недостаточно прав для просмотра этой страницы.", + "link": "/profile", + "link_text": "В личный кабинет", + "jivosite_widget_id": settings.JIVOSITE_WIDGET_ID, + }, status_code=403) diff --git a/web/migrations/versions/0002_users_and_connections.py b/web/migrations/versions/0002_users_and_connections.py new file mode 100644 index 0000000..7a2288e --- /dev/null +++ b/web/migrations/versions/0002_users_and_connections.py @@ -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}")) diff --git a/web/migrations/versions/0003_rbac_tables.py b/web/migrations/versions/0003_rbac_tables.py new file mode 100644 index 0000000..fe88044 --- /dev/null +++ b/web/migrations/versions/0003_rbac_tables.py @@ -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}")) diff --git a/web/models/__init__.py b/web/models/__init__.py index e69de29..0208b15 100644 --- a/web/models/__init__.py +++ b/web/models/__init__.py @@ -0,0 +1,11 @@ +from web.models.user import User, UserRoleEnum, UserStatusEnum # noqa: F401 +from web.models.rbac import Role, Permission, role_permissions, UserRole # noqa: F401 +from web.models.connections import ( # noqa: F401 + EvotorConnection, + VkConnection, + SyncConfig, + SyncFilter, + CachedStore, + CachedGroup, + CachedProduct, +) diff --git a/web/models/connections.py b/web/models/connections.py new file mode 100644 index 0000000..0d9972a --- /dev/null +++ b/web/models/connections.py @@ -0,0 +1,136 @@ +from sqlalchemy import ( + Boolean, Column, DateTime, ForeignKey, Index, Integer, + Numeric, String, Text, UniqueConstraint, func, +) + +from web.database import Base + + +class EvotorConnection(Base): + __tablename__ = "evotor_connections" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) + evotor_user_id = Column(String(255), nullable=True) + access_token = Column(Text, nullable=False) + api_token = Column(String(255), nullable=True) # token we return to Evotor in webhook responses + store_id = Column(String(255), nullable=True) + store_name = Column(String(255), nullable=True) + refresh_token = Column(Text, nullable=True) + token_expires_at = Column(DateTime, nullable=True) + is_online = Column(Boolean, nullable=False, default=False) + last_checked_at = Column(DateTime, nullable=True) + connected_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint("user_id", name="ix_evotor_connections_user_id"), + UniqueConstraint("evotor_user_id", name="ix_evotor_connections_evotor_user_id"), + ) + + +class VkConnection(Base): + __tablename__ = "vk_connections" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + access_token = Column(Text, nullable=False) + vk_user_id = Column(String(50), nullable=True) + first_name = Column(String(255), nullable=True) + last_name = Column(String(255), nullable=True) + is_online = Column(Boolean, nullable=False, default=False) + last_checked_at = Column(DateTime, nullable=True) + connected_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint("user_id", name="ix_vk_connections_user_id"), + ) + + +class SyncConfig(Base): + __tablename__ = "sync_configs" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + is_enabled = Column(Boolean, nullable=False, default=False) + confirmed_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint("user_id", name="ix_sync_configs_user_id"), + ) + + +class SyncFilter(Base): + __tablename__ = "sync_filters" + + id = Column(Integer, primary_key=True, autoincrement=True) + sync_config_id = Column(Integer, ForeignKey("sync_configs.id", ondelete="CASCADE"), nullable=False) + entity_type = Column(String(20), nullable=False) + entity_id = Column(String(255), nullable=False) + entity_name = Column(String(255), nullable=True) + filter_mode = Column(String(10), nullable=False) + parent_entity_id = Column(String(255), nullable=True) + created_at = Column(DateTime, nullable=False, server_default=func.now()) + + __table_args__ = ( + UniqueConstraint("sync_config_id", "entity_type", "entity_id", + name="uq_sync_filters_config_type_entity"), + ) + + +class CachedStore(Base): + __tablename__ = "cached_stores" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + evotor_id = Column(String(255), nullable=False) + name = Column(String(255), nullable=False) + address = Column(String(500), nullable=True) + fetched_at = Column(DateTime, nullable=False) + + __table_args__ = ( + UniqueConstraint("user_id", "evotor_id", name="uq_cached_stores_user_evotor"), + Index("ix_cached_stores_user_id", "user_id"), + ) + + +class CachedGroup(Base): + __tablename__ = "cached_groups" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + evotor_id = Column(String(255), nullable=False) + store_evotor_id = Column(String(255), nullable=False) + name = Column(String(255), nullable=False) + fetched_at = Column(DateTime, nullable=False) + + __table_args__ = ( + UniqueConstraint("user_id", "evotor_id", name="uq_cached_groups_user_evotor"), + Index("ix_cached_groups_user_store", "user_id", "store_evotor_id"), + ) + + +class CachedProduct(Base): + __tablename__ = "cached_products" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + evotor_id = Column(String(255), nullable=False) + store_evotor_id = Column(String(255), nullable=False) + group_evotor_id = Column(String(255), nullable=True) + name = Column(String(255), nullable=False) + price = Column(Numeric(12, 2), nullable=True) + quantity = Column(Numeric(12, 3), nullable=True) + measure_name = Column(String(20), nullable=True) + article_number = Column(String(100), nullable=True) + allow_to_sell = Column(Boolean, nullable=True) + fetched_at = Column(DateTime, nullable=False) + synced_at = Column(DateTime, nullable=True) + + __table_args__ = ( + UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"), + Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"), + ) diff --git a/web/models/rbac.py b/web/models/rbac.py new file mode 100644 index 0000000..840486d --- /dev/null +++ b/web/models/rbac.py @@ -0,0 +1,34 @@ +from sqlalchemy import Column, ForeignKey, Integer, String, Table + +from web.database import Base + + +role_permissions = Table( + "role_permissions", + Base.metadata, + Column("role_id", Integer, ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True), + Column("permission_id", Integer, ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True), +) + + +class Role(Base): + __tablename__ = "roles" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(50), nullable=False, unique=True) + description = Column(String(255), nullable=True) + + +class Permission(Base): + __tablename__ = "permissions" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False, unique=True) + description = Column(String(255), nullable=True) + + +class UserRole(Base): + __tablename__ = "user_roles" + + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True) + role_id = Column(Integer, ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True) diff --git a/web/models/user.py b/web/models/user.py new file mode 100644 index 0000000..e8d29e4 --- /dev/null +++ b/web/models/user.py @@ -0,0 +1,50 @@ +import enum + +from sqlalchemy import Boolean, Column, DateTime, Enum, Index, Integer, JSON, String, UniqueConstraint, func + +from web.database import Base + + +class UserRoleEnum(str, enum.Enum): + system = "system" + admin = "admin" + user = "user" + + +class UserStatusEnum(str, enum.Enum): + pending = "pending" + active = "active" + suspended = "suspended" + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + first_name = Column(String(100), nullable=False) + last_name = Column(String(100), nullable=False) + email = Column(String(255), nullable=False) + phone = Column(String(20), nullable=False) + password_hash = Column(String(255), nullable=True) + is_email_confirmed = Column(Boolean, nullable=False, default=False) + email_confirm_token = Column(String(255), nullable=True) + password_reset_token = Column(String(255), nullable=True) + password_reset_expires = Column(DateTime, nullable=True) + role = Column(Enum(UserRoleEnum), nullable=False, default=UserRoleEnum.user) + status = Column(Enum(UserStatusEnum), nullable=False, default=UserStatusEnum.pending) + evotor_user_id = Column(String(255), nullable=True) + evotor_meta = Column(JSON, nullable=True) + invite_token = Column(String(255), nullable=True) + invite_expires = Column(DateTime, nullable=True) + phone_otp = Column(String(10), nullable=True) + phone_otp_expires = Column(DateTime, nullable=True) + created_at = Column(DateTime, nullable=False, server_default=func.now()) + updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint("email", name="ix_users_email"), + UniqueConstraint("phone", name="ix_users_phone"), + UniqueConstraint("evotor_user_id", name="ix_users_evotor_user_id"), + Index("ix_users_role", "role"), + Index("ix_users_status", "status"), + ) diff --git a/web/notifications/__init__.py b/web/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/notifications/base.py b/web/notifications/base.py new file mode 100644 index 0000000..42ca879 --- /dev/null +++ b/web/notifications/base.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + + +class EmailProvider(ABC): + @abstractmethod + def send(self, to: str, subject: str, html_body: str) -> None: ... + + +class SMSProvider(ABC): + @abstractmethod + def send(self, to: str, text: str) -> None: ... diff --git a/web/notifications/console.py b/web/notifications/console.py new file mode 100644 index 0000000..5004219 --- /dev/null +++ b/web/notifications/console.py @@ -0,0 +1,28 @@ +import logging +import re + +from web.notifications.base import EmailProvider, SMSProvider + +logger = logging.getLogger(__name__) + + +class ConsoleEmailProvider(EmailProvider): + def send(self, to: str, subject: str, html_body: str) -> None: + # Extract plain URLs from HTML for readability in dev logs + urls = re.findall(r'href=["\']([^"\']+)["\']', html_body) + logger.info("=" * 50) + logger.info("EMAIL") + logger.info("Кому: %s", to) + logger.info("Тема: %s", subject) + for url in urls: + logger.info("Ссылка: %s", url) + logger.info("=" * 50) + + +class ConsoleSMSProvider(SMSProvider): + def send(self, to: str, text: str) -> None: + logger.info("=" * 50) + logger.info("SMS") + logger.info("Номер: %s", to) + logger.info("Текст: %s", text) + logger.info("=" * 50) diff --git a/web/notifications/registry.py b/web/notifications/registry.py new file mode 100644 index 0000000..003ae09 --- /dev/null +++ b/web/notifications/registry.py @@ -0,0 +1,17 @@ +from web.config import settings +from web.notifications.base import EmailProvider, SMSProvider +from web.notifications.console import ConsoleEmailProvider, ConsoleSMSProvider + + +def get_email_provider() -> EmailProvider: + provider = settings.EMAIL_PROVIDER + if provider == "console": + return ConsoleEmailProvider() + raise ValueError(f"Unknown EMAIL_PROVIDER: {provider!r}") + + +def get_sms_provider() -> SMSProvider: + provider = settings.SMS_PROVIDER + if provider == "console": + return ConsoleSMSProvider() + raise ValueError(f"Unknown SMS_PROVIDER: {provider!r}") diff --git a/web/notifications/tasks.py b/web/notifications/tasks.py new file mode 100644 index 0000000..2d9aacd --- /dev/null +++ b/web/notifications/tasks.py @@ -0,0 +1,12 @@ +from web.tasks.celery_app import celery_app +from web.notifications.registry import get_email_provider, get_sms_provider + + +@celery_app.task(name="web.notifications.tasks.send_email_task", queue="notifications") +def send_email_task(to: str, subject: str, html_body: str) -> None: + get_email_provider().send(to, subject, html_body) + + +@celery_app.task(name="web.notifications.tasks.send_sms_task", queue="notifications") +def send_sms_task(to: str, text: str) -> None: + get_sms_provider().send(to, text) diff --git a/web/routes/__init__.py b/web/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/routes/admin.py b/web/routes/admin.py new file mode 100644 index 0000000..772b2a0 --- /dev/null +++ b/web/routes/admin.py @@ -0,0 +1,271 @@ +import secrets +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session + +from web.auth.password import hash_password +from web.auth.rbac import require_role +from web.auth.session import get_current_user +from web.config import settings +from web.database import get_db +from web.models.rbac import Permission, Role, UserRole, role_permissions +from web.models.user import User, UserRoleEnum, UserStatusEnum +from web.notifications.tasks import send_email_task +from web.templates_env import templates + +router = APIRouter(prefix="/admin") + +PAGE_SIZE = 25 + + +def _render(request: Request, template: str, ctx: dict) -> HTMLResponse: + ctx["request"] = request + ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID) + return templates.TemplateResponse(template, ctx) + + +def _admin_user(request: Request, db: Session) -> User: + """Get current user and verify admin/system role.""" + try: + user = get_current_user(request, db) + except Exception: + raise + if user.role not in (UserRoleEnum.admin, UserRoleEnum.system): + from fastapi import HTTPException + raise HTTPException(403, "Недостаточно прав") + return user + + +# ── User list ───────────────────────────────────────────────────────────────── + +@router.get("/users") +async def admin_users(request: Request, db: Session = Depends(get_db)): + try: + admin = _admin_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + + q = db.query(User) + search = request.query_params.get("search", "").strip() + status_filter = request.query_params.get("status", "") + role_filter = request.query_params.get("role", "") + page = max(1, int(request.query_params.get("page", 1))) + + if search: + q = q.filter( + (User.first_name.ilike(f"%{search}%")) | + (User.last_name.ilike(f"%{search}%")) | + (User.email.ilike(f"%{search}%")) | + (User.phone.ilike(f"%{search}%")) + ) + if status_filter: + try: + q = q.filter(User.status == UserStatusEnum(status_filter)) + except ValueError: + pass + if role_filter: + try: + q = q.filter(User.role == UserRoleEnum(role_filter)) + except ValueError: + pass + + total = q.count() + users = q.order_by(User.created_at.desc()).offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).all() + total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) + + return _render(request, "admin/users.html", { + "user": admin, + "users": users, + "search": search, + "status_filter": status_filter, + "role_filter": role_filter, + "page": page, + "total_pages": total_pages, + "total": total, + }) + + +# ── User detail ─────────────────────────────────────────────────────────────── + +@router.get("/users/{user_id}") +async def admin_user_detail(user_id: int, request: Request, db: Session = Depends(get_db)): + try: + admin = _admin_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + target = db.get(User, user_id) + if not target: + return RedirectResponse("/admin/users", 303) + return _render(request, "admin/user_detail.html", {"user": admin, "target": target}) + + +# ── User actions ────────────────────────────────────────────────────────────── + +@router.post("/users/{user_id}/activate") +async def admin_activate(user_id: int, request: Request, db: Session = Depends(get_db)): + try: + _admin_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + user = db.get(User, user_id) + if user: + user.status = UserStatusEnum.active + db.commit() + return RedirectResponse(f"/admin/users/{user_id}", 303) + + +@router.post("/users/{user_id}/suspend") +async def admin_suspend(user_id: int, request: Request, db: Session = Depends(get_db)): + try: + _admin_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + user = db.get(User, user_id) + if user: + user.status = UserStatusEnum.suspended + db.commit() + return RedirectResponse(f"/admin/users/{user_id}", 303) + + +@router.post("/users/{user_id}/reset-password") +async def admin_reset_password(user_id: int, request: Request, db: Session = Depends(get_db)): + try: + _admin_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + user = db.get(User, user_id) + if user: + token = secrets.token_urlsafe(32) + user.password_reset_token = token + user.password_reset_expires = datetime.utcnow() + timedelta( + minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES + ) + db.commit() + reset_url = f"{settings.BASE_URL}/reset-password?token={token}" + html = f'

Сброс пароля (запрошен администратором): {reset_url}

' + send_email_task.delay(user.email, "Сброс пароля — ЭВОСИНК", html) + return RedirectResponse(f"/admin/users/{user_id}?success=reset_sent", 303) + + +@router.post("/users/{user_id}/send-invite") +async def admin_send_invite(user_id: int, request: Request, db: Session = Depends(get_db)): + try: + _admin_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + user = db.get(User, user_id) + if user: + token = secrets.token_urlsafe(32) + user.invite_token = token + user.invite_expires = datetime.utcnow() + timedelta(hours=settings.INVITE_EXPIRE_HOURS) + db.commit() + invite_url = f"{settings.BASE_URL}/invite?token={token}" + html = ( + f"

Вам отправлено приглашение в ЭВОСИНК.

" + f'

{invite_url}

' + f"

Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.

" + ) + send_email_task.delay(user.email, "Приглашение в ЭВОСИНК", html) + return RedirectResponse(f"/admin/users/{user_id}?success=invite_sent", 303) + + +@router.post("/users/{user_id}/edit") +async def admin_edit_user(user_id: int, request: Request, db: Session = Depends(get_db)): + try: + admin = _admin_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + user = db.get(User, user_id) + if not user: + return RedirectResponse("/admin/users", 303) + + form = await request.form() + data = {k: str(v).strip() for k, v in form.items()} + errors = [] + + if not data.get("first_name"): + errors.append("Имя обязательно") + if not data.get("last_name"): + errors.append("Фамилия обязательна") + + if errors: + return _render(request, "admin/user_detail.html", { + "user": admin, "target": user, "errors": errors, + }) + + user.first_name = data["first_name"] + user.last_name = data["last_name"] + if data.get("email"): + user.email = data["email"] + if data.get("phone"): + user.phone = data["phone"] + if data.get("role") and admin.role == UserRoleEnum.system: + try: + user.role = UserRoleEnum(data["role"]) + except ValueError: + pass + db.commit() + return RedirectResponse(f"/admin/users/{user_id}?success=saved", 303) + + +@router.post("/users/{user_id}/delete") +async def admin_delete_user(user_id: int, request: Request, db: Session = Depends(get_db)): + try: + admin = _admin_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + if admin.role != UserRoleEnum.system: + return RedirectResponse(f"/admin/users/{user_id}", 303) + user = db.get(User, user_id) + if user: + db.delete(user) + db.commit() + return RedirectResponse("/admin/users", 303) + + +# ── Roles ───────────────────────────────────────────────────────────────────── + +@router.get("/roles") +async def admin_roles(request: Request, db: Session = Depends(get_db)): + try: + admin = _admin_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + if admin.role != UserRoleEnum.system: + return RedirectResponse("/admin/users", 303) + roles = db.query(Role).order_by(Role.id).all() + permissions = db.query(Permission).order_by(Permission.name).all() + role_perm_ids: dict[int, set[int]] = {} + for role in roles: + rows = db.execute( + role_permissions.select().where(role_permissions.c.role_id == role.id) + ).fetchall() + role_perm_ids[role.id] = {r.permission_id for r in rows} + return _render(request, "admin/roles.html", { + "user": admin, "roles": roles, "permissions": permissions, + "role_perm_ids": role_perm_ids, + }) + + +@router.post("/roles/{role_id}/permissions") +async def admin_update_role_permissions( + role_id: int, request: Request, db: Session = Depends(get_db) +): + try: + admin = _admin_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + if admin.role != UserRoleEnum.system: + return RedirectResponse("/admin/roles", 303) + + form = await request.form() + selected_ids = {int(v) for k, v in form.items() if k.startswith("perm_")} + + # Remove all existing, re-insert selected + db.execute(role_permissions.delete().where(role_permissions.c.role_id == role_id)) + for perm_id in selected_ids: + db.execute(role_permissions.insert().values(role_id=role_id, permission_id=perm_id)) + db.commit() + return RedirectResponse("/admin/roles", 303) diff --git a/web/routes/auth.py b/web/routes/auth.py new file mode 100644 index 0000000..c5904ab --- /dev/null +++ b/web/routes/auth.py @@ -0,0 +1,170 @@ +import secrets + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from web.auth.password import hash_password, verify_password +from web.auth.session import get_session_user_id +from web.config import settings +from web.database import get_db +from web.models.user import User, UserStatusEnum +from web.notifications.tasks import send_email_task +from web.templates_env import templates + +router = APIRouter() + + +def _render(request: Request, template: str, ctx: dict) -> HTMLResponse: + ctx["request"] = request + ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID) + return templates.TemplateResponse(template, ctx) + + +@router.get("/register") +async def register_get(request: Request, db: Session = Depends(get_db)): + if get_session_user_id(request): + return RedirectResponse("/profile", 303) + return _render(request, "register.html", {"user": None}) + + +@router.post("/register") +async def register_post(request: Request, db: Session = Depends(get_db)): + form = await request.form() + data = {k: str(v).strip() for k, v in form.items()} + errors = [] + + if not data.get("email"): + errors.append("Email обязателен") + if not data.get("phone"): + errors.append("Телефон обязателен") + if not data.get("password"): + errors.append("Пароль обязателен") + if len(data.get("password", "")) < 8: + errors.append("Пароль должен содержать минимум 8 символов") + if data.get("password") != data.get("password_confirm"): + errors.append("Пароли не совпадают") + + if not errors: + existing = db.query(User).filter( + or_(User.email == data["email"], User.phone == data["phone"]) + ).first() + if existing: + if existing.email == data["email"]: + errors.append("Пользователь с таким email уже существует") + else: + errors.append("Пользователь с таким телефоном уже существует") + + if errors: + return _render(request, "register.html", {"user": None, "errors": errors, "form": data}) + + token = secrets.token_urlsafe(32) + user = User( + first_name=data.get("first_name", ""), + last_name=data.get("last_name", ""), + email=data["email"], + phone=data["phone"], + password_hash=await _hash(data["password"]), + email_confirm_token=token, + status=UserStatusEnum.pending, + ) + db.add(user) + db.commit() + + confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}" + html = f'

Подтвердите email: {confirm_url}

' + send_email_task.delay(user.email, "Подтвердите ваш email — ЭВОСИНК", html) + + return _render(request, "confirm_email.html", {"user": None}) + + +@router.get("/confirm-email") +async def confirm_email(request: Request, db: Session = Depends(get_db)): + token = request.query_params.get("token") + if not token: + return _render(request, "message.html", { + "user": None, "title": "Ошибка", "message": "Неверная или устаревшая ссылка.", + "link": "/login", "link_text": "Войти", + }) + user = db.query(User).filter(User.email_confirm_token == token).first() + if not user: + return _render(request, "message.html", { + "user": None, "title": "Ошибка", "message": "Неверная или устаревшая ссылка.", + "link": "/login", "link_text": "Войти", + }) + user.is_email_confirmed = True + user.email_confirm_token = None + user.status = UserStatusEnum.active + db.commit() + return _render(request, "email_confirmed.html", {"user": None}) + + +@router.get("/resend-confirm") +async def resend_confirm(request: Request, db: Session = Depends(get_db)): + user_id = get_session_user_id(request) + if not user_id: + return RedirectResponse("/login", 303) + user = db.get(User, user_id) + if not user or user.is_email_confirmed: + return RedirectResponse("/profile", 303) + token = secrets.token_urlsafe(32) + user.email_confirm_token = token + db.commit() + confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}" + html = f'

Подтвердите email: {confirm_url}

' + send_email_task.delay(user.email, "Подтвердите ваш email — ЭВОСИНК", html) + return _render(request, "message.html", { + "user": user, + "title": "Письмо отправлено", + "message": "Проверьте почту и нажмите на ссылку для подтверждения.", + "link": "/profile", "link_text": "Назад", + }) + + +@router.get("/login") +async def login_get(request: Request, db: Session = Depends(get_db)): + if get_session_user_id(request): + return RedirectResponse("/profile", 303) + return _render(request, "login.html", {"user": None}) + + +@router.post("/login") +async def login_post(request: Request, db: Session = Depends(get_db)): + form = await request.form() + email = str(form.get("email", "")).strip() + password = str(form.get("password", "")) + errors = [] + + if not email: + errors.append("Email обязателен") + if not password: + errors.append("Пароль обязателен") + + if not errors: + user = db.query(User).filter(User.email == email).first() + if not user or not user.password_hash or not verify_password(password, user.password_hash): + errors.append("Неверный email или пароль") + elif user.status == UserStatusEnum.suspended: + errors.append("Ваш аккаунт заблокирован. Обратитесь к администратору.") + elif not user.is_email_confirmed: + errors.append("Пожалуйста, подтвердите ваш email") + + if errors: + return _render(request, "login.html", { + "user": None, "errors": errors, "form": {"email": email}, + }) + + request.session["user_id"] = user.id + return RedirectResponse("/profile", 303) + + +@router.get("/logout") +async def logout(request: Request): + request.session.clear() + return RedirectResponse("/login", 303) + + +async def _hash(plain: str) -> str: + import asyncio + return await asyncio.get_event_loop().run_in_executor(None, hash_password, plain) diff --git a/web/routes/evotor_webhooks.py b/web/routes/evotor_webhooks.py new file mode 100644 index 0000000..bd3ee1b --- /dev/null +++ b/web/routes/evotor_webhooks.py @@ -0,0 +1,269 @@ +""" +Evotor webhook endpoints. + +POST /user/create — Evotor creates a new subscriber; we create/link a local user and return a token. +POST /user/verify — Evotor verifies credentials for a user trying to log in via the Evotor interface. +POST /user/token — Evotor sends us its own API token for the user. +""" +import json +import logging +import secrets +from datetime import datetime, timedelta +from typing import Any + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from web.auth.password import verify_password +from web.config import settings +from web.database import get_db +from web.models.connections import EvotorConnection +from web.models.user import User, UserRoleEnum, UserStatusEnum +from web.notifications.tasks import send_email_task + +logger = logging.getLogger(__name__) +router = APIRouter() + +EVOTOR_STORES_URL = "https://api.evotor.ru/stores" + + +def _verify_secret(request: Request) -> bool: + secret = settings.EVOTOR_WEBHOOK_SECRET + if not secret: + return True # dev mode: no secret configured + auth = request.headers.get("Authorization", "") + return auth == f"Bearer {secret}" + + +def _parse_custom_fields(raw: Any) -> dict: + """Extract known fields from Evotor customField (may be JSON string or dict).""" + if raw is None: + return {} + if isinstance(raw, dict): + return raw + if isinstance(raw, str): + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + return parsed + except (json.JSONDecodeError, ValueError): + pass + return {} + + +def _upsert_evotor_connection( + db: Session, + user_id: int | None, + evotor_user_id: str, + access_token: str | None = None, +) -> str: + """Create or update an evotor_connections row; always regenerates api_token.""" + api_token = secrets.token_urlsafe(32) + conn = db.query(EvotorConnection).filter( + EvotorConnection.evotor_user_id == evotor_user_id + ).first() + now = datetime.utcnow() + if conn: + conn.api_token = api_token + if user_id is not None: + conn.user_id = user_id + if access_token: + conn.access_token = access_token + conn.updated_at = now + else: + conn = EvotorConnection( + user_id=user_id, + evotor_user_id=evotor_user_id, + access_token=access_token or "", + api_token=api_token, + connected_at=now, + updated_at=now, + ) + db.add(conn) + db.flush() + return api_token + + +@router.post("/user/create") +async def user_create(request: Request, db: Session = Depends(get_db)): + if not _verify_secret(request): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "Invalid JSON"}, status_code=400) + + evotor_user_id: str = body.get("userId", "") + if not evotor_user_id: + return JSONResponse({"error": "userId required"}, status_code=400) + + custom = _parse_custom_fields(body.get("customField")) + email = (custom.get("email") or "").strip().lower() or None + phone = (custom.get("phone") or "").strip() or None + first_name = (custom.get("first_name") or custom.get("firstName") or "").strip() or None + last_name = (custom.get("last_name") or custom.get("lastName") or "").strip() or None + + # Try to find existing user + user: User | None = None + + # 1. By evotor_user_id + user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first() + + # 2. By email + if user is None and email: + user = db.query(User).filter(User.email == email).first() + + # 3. By phone + if user is None and phone: + user = db.query(User).filter(User.phone == phone).first() + + now = datetime.utcnow() + + if user: + # Link Evotor to existing user + user.evotor_user_id = evotor_user_id + user.evotor_meta = custom or body + if user.status == UserStatusEnum.pending: + user.status = UserStatusEnum.active + db.flush() + else: + # Create new pending user + user = User( + first_name=first_name or "", + last_name=last_name or "", + email=email or f"{evotor_user_id}@evotor.placeholder", + phone=phone or "", + password_hash=None, + role=UserRoleEnum.user, + status=UserStatusEnum.pending, + evotor_user_id=evotor_user_id, + evotor_meta=custom or body, + created_at=now, + updated_at=now, + ) + db.add(user) + db.flush() # get user.id + + # Generate invite + invite_token = secrets.token_urlsafe(32) + user.invite_token = invite_token + user.invite_expires = now + timedelta(hours=settings.INVITE_EXPIRE_HOURS) + + api_token = _upsert_evotor_connection(db, user.id, evotor_user_id) + db.commit() + + # Send invite email if we have a real email address + if email: + invite_url = f"{settings.BASE_URL}/invite?token={invite_token}" + html = ( + f"

Здравствуйте!

" + f"

Вам открыт доступ к ЭВОСИНК. Завершите регистрацию по ссылке:

" + f'

{invite_url}

' + f"

Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.

" + ) + send_email_task.delay(email, "Приглашение в ЭВОСИНК", html) + else: + logger.info("No email for evotor_user_id=%s, invite URL: %s/invite?token=%s", + evotor_user_id, settings.BASE_URL, invite_token) + + return JSONResponse({"userId": evotor_user_id, "token": api_token}) + + +@router.post("/user/verify") +async def user_verify(request: Request, db: Session = Depends(get_db)): + if not _verify_secret(request): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "Invalid JSON"}, status_code=400) + + evotor_user_id: str = body.get("userId", "") + username: str = body.get("username", "").strip() + password: str = body.get("password", "") + + if not username or not password: + return JSONResponse({"error": "username and password required"}, status_code=400) + + # username is email or phone + user = db.query(User).filter( + or_(User.email == username, User.phone == username) + ).first() + + if not user or not user.password_hash: + return JSONResponse({"error": "Неверные данные"}, status_code=401) + + if user.status == UserStatusEnum.suspended: + return JSONResponse({"error": "Аккаунт заблокирован"}, status_code=403) + + if not verify_password(password, user.password_hash): + return JSONResponse({"error": "Неверные данные"}, status_code=401) + + # Get or create connection to retrieve api_token + conn = db.query(EvotorConnection).filter( + EvotorConnection.evotor_user_id == (user.evotor_user_id or evotor_user_id) + ).first() + if not conn: + # Auto-link: create connection with Evotor userId from request + if evotor_user_id and not user.evotor_user_id: + user.evotor_user_id = evotor_user_id + db.flush() + api_token = _upsert_evotor_connection(db, user.id, evotor_user_id or (user.evotor_user_id or "")) + db.commit() + else: + api_token = conn.api_token or secrets.token_urlsafe(32) + if not conn.api_token: + conn.api_token = api_token + db.commit() + + return JSONResponse({"userId": user.evotor_user_id or evotor_user_id, "token": api_token}) + + +@router.post("/user/token") +async def user_token(request: Request, db: Session = Depends(get_db)): + if not _verify_secret(request): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "Invalid JSON"}, status_code=400) + + evotor_user_id: str = body.get("userId", "") + evotor_token: str = body.get("token", "") + + if not evotor_user_id or not evotor_token: + return JSONResponse({"error": "userId and token required"}, status_code=400) + + user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first() + if not user: + return JSONResponse({"error": "User not found"}, status_code=404) + + conn = db.query(EvotorConnection).filter( + EvotorConnection.evotor_user_id == evotor_user_id + ).first() + now = datetime.utcnow() + if conn: + conn.access_token = evotor_token + conn.is_online = True + conn.last_checked_at = now + conn.updated_at = now + else: + conn = EvotorConnection( + user_id=user.id, + evotor_user_id=evotor_user_id, + access_token=evotor_token, + api_token=secrets.token_urlsafe(32), + is_online=True, + last_checked_at=now, + connected_at=now, + updated_at=now, + ) + db.add(conn) + db.commit() + + return JSONResponse({}) diff --git a/web/routes/invite.py b/web/routes/invite.py new file mode 100644 index 0000000..49dd44b --- /dev/null +++ b/web/routes/invite.py @@ -0,0 +1,99 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from web.auth.password import hash_password +from web.config import settings +from web.database import get_db +from web.models.user import User, UserStatusEnum +from web.templates_env import templates + +router = APIRouter() + + +def _render(request: Request, template: str, ctx: dict) -> HTMLResponse: + ctx["request"] = request + ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID) + return templates.TemplateResponse(template, ctx) + + +def _bad_token(request: Request) -> HTMLResponse: + return _render(request, "message.html", { + "user": None, + "title": "Ссылка недействительна", + "message": "Ссылка приглашения устарела или недействительна. Обратитесь к администратору.", + "link": "/login", "link_text": "Войти", + }) + + +@router.get("/invite") +async def invite_get(request: Request, db: Session = Depends(get_db)): + token = request.query_params.get("token", "") + user = db.query(User).filter(User.invite_token == token).first() + if not user or not user.invite_expires or user.invite_expires < datetime.utcnow(): + return _bad_token(request) + return _render(request, "invite.html", {"user": None, "invite_user": user, "token": token}) + + +@router.post("/invite") +async def invite_post(request: Request, db: Session = Depends(get_db)): + token = request.query_params.get("token", "") + invite_user = db.query(User).filter(User.invite_token == token).first() + if not invite_user or not invite_user.invite_expires or invite_user.invite_expires < datetime.utcnow(): + return _bad_token(request) + + form = await request.form() + data = {k: str(v).strip() for k, v in form.items()} + errors = [] + + if not data.get("first_name"): + errors.append("Имя обязательно") + if not data.get("last_name"): + errors.append("Фамилия обязательна") + if not data.get("email"): + errors.append("Email обязателен") + if not data.get("phone"): + errors.append("Телефон обязателен") + if len(data.get("password", "")) < 8: + errors.append("Пароль должен содержать минимум 8 символов") + if data.get("password") != data.get("password_confirm"): + errors.append("Пароли не совпадают") + + if not errors: + # Check uniqueness (excluding current invite_user) + dup = db.query(User).filter( + or_(User.email == data["email"], User.phone == data["phone"]), + User.id != invite_user.id, + ).first() + if dup: + if dup.email == data["email"]: + errors.append("Пользователь с таким email уже существует") + else: + errors.append("Пользователь с таким телефоном уже существует") + + if errors: + return _render(request, "invite.html", { + "user": None, "invite_user": invite_user, "token": token, + "errors": errors, "form": data, + }) + + invite_user.first_name = data["first_name"] + invite_user.last_name = data["last_name"] + invite_user.email = data["email"] + invite_user.phone = data["phone"] + invite_user.password_hash = hash_password(data["password"]) + invite_user.is_email_confirmed = True + invite_user.status = UserStatusEnum.active + invite_user.invite_token = None + invite_user.invite_expires = None + db.commit() + + return _render(request, "message.html", { + "user": None, + "title": "Регистрация завершена!", + "message": "Ваш аккаунт активирован. Теперь вы можете войти.", + "link": "/login", "link_text": "Войти", + }) diff --git a/web/routes/profile.py b/web/routes/profile.py new file mode 100644 index 0000000..30e8e08 --- /dev/null +++ b/web/routes/profile.py @@ -0,0 +1,143 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from web.auth.password import hash_password, verify_password +from web.auth.session import get_current_user +from web.config import settings +from web.database import get_db +from web.models.user import User +from web.templates_env import templates + +router = APIRouter() + + +def _render(request: Request, template: str, ctx: dict) -> HTMLResponse: + ctx["request"] = request + ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID) + return templates.TemplateResponse(template, ctx) + + +@router.get("/profile") +async def profile_view(request: Request, db: Session = Depends(get_db)): + try: + user = get_current_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + return _render(request, "profile_view.html", {"user": user}) + + +@router.get("/profile/edit") +async def profile_edit_get(request: Request, db: Session = Depends(get_db)): + try: + user = get_current_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + return _render(request, "profile_edit.html", {"user": user}) + + +@router.post("/profile/edit") +async def profile_edit_post(request: Request, db: Session = Depends(get_db)): + try: + user = get_current_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + + form = await request.form() + data = {k: str(v).strip() for k, v in form.items()} + errors = [] + + if not data.get("first_name"): + errors.append("Имя обязательно") + if not data.get("last_name"): + errors.append("Фамилия обязательна") + if not data.get("phone"): + errors.append("Телефон обязателен") + + if not errors: + dup = db.query(User).filter( + User.phone == data["phone"], User.id != user.id + ).first() + if dup: + errors.append("Пользователь с таким телефоном уже существует") + + if errors: + return _render(request, "profile_edit.html", {"user": user, "errors": errors, "form": data}) + + user.first_name = data["first_name"] + user.last_name = data["last_name"] + user.phone = data["phone"] + db.commit() + return _render(request, "profile_edit.html", { + "user": user, "success": "Профиль обновлён", + }) + + +@router.get("/profile/change-password") +async def change_pw_get(request: Request, db: Session = Depends(get_db)): + try: + user = get_current_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + return _render(request, "profile_change_password.html", {"user": user}) + + +@router.post("/profile/change-password") +async def change_pw_post(request: Request, db: Session = Depends(get_db)): + try: + user = get_current_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + + form = await request.form() + current = str(form.get("current_password", "")) + new_pw = str(form.get("password", "")) + confirm = str(form.get("password_confirm", "")) + errors = [] + + if not user.password_hash or not verify_password(current, user.password_hash): + errors.append("Неверный текущий пароль") + if len(new_pw) < 8: + errors.append("Новый пароль должен содержать минимум 8 символов") + if new_pw != confirm: + errors.append("Пароли не совпадают") + + if errors: + return _render(request, "profile_change_password.html", {"user": user, "errors": errors}) + + user.password_hash = hash_password(new_pw) + db.commit() + return _render(request, "profile_change_password.html", { + "user": user, "success": "Пароль изменён", + }) + + +@router.get("/profile/delete") +async def delete_get(request: Request, db: Session = Depends(get_db)): + try: + user = get_current_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + return _render(request, "profile_delete.html", {"user": user}) + + +@router.post("/profile/delete") +async def delete_post(request: Request, db: Session = Depends(get_db)): + try: + user = get_current_user(request, db) + except Exception: + return RedirectResponse("/login", 303) + + form = await request.form() + password = str(form.get("password", "")) + + if not user.password_hash or not verify_password(password, user.password_hash): + return _render(request, "profile_delete.html", { + "user": user, "errors": ["Неверный пароль"], + }) + + db.delete(user) + db.commit() + request.session.clear() + return RedirectResponse("/login", 303) diff --git a/web/routes/reset.py b/web/routes/reset.py new file mode 100644 index 0000000..a2cffa5 --- /dev/null +++ b/web/routes/reset.py @@ -0,0 +1,101 @@ +import secrets +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session + +from web.auth.password import hash_password +from web.config import settings +from web.database import get_db +from web.models.user import User +from web.notifications.tasks import send_email_task +from web.templates_env import templates + +router = APIRouter() + + +def _render(request: Request, template: str, ctx: dict) -> HTMLResponse: + ctx["request"] = request + ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID) + return templates.TemplateResponse(template, ctx) + + +@router.get("/forgot-password") +async def forgot_get(request: Request): + return _render(request, "forgot_password.html", {"user": None}) + + +@router.post("/forgot-password") +async def forgot_post(request: Request, db: Session = Depends(get_db)): + form = await request.form() + email = str(form.get("email", "")).strip() + user = db.query(User).filter(User.email == email).first() + if user: + token = secrets.token_urlsafe(32) + user.password_reset_token = token + user.password_reset_expires = datetime.utcnow() + timedelta( + minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES + ) + db.commit() + reset_url = f"{settings.BASE_URL}/reset-password?token={token}" + html = f'

Сброс пароля: {reset_url}

' + send_email_task.delay(user.email, "Сброс пароля — ЭВОСИНК", html) + # Always show same message to prevent user enumeration + return _render(request, "message.html", { + "user": None, + "title": "Ссылка отправлена", + "message": "Если указанный email зарегистрирован, вы получите ссылку для сброса пароля.", + "link": "/login", "link_text": "Войти", + }) + + +@router.get("/reset-password") +async def reset_get(request: Request, db: Session = Depends(get_db)): + token = request.query_params.get("token", "") + user = db.query(User).filter(User.password_reset_token == token).first() + if not user or not user.password_reset_expires or user.password_reset_expires < datetime.utcnow(): + return _render(request, "message.html", { + "user": None, "title": "Ссылка недействительна", + "message": "Ссылка для сброса пароля устарела или недействительна.", + "link": "/forgot-password", "link_text": "Запросить новую ссылку", + }) + return _render(request, "reset_password.html", {"user": None, "token": token}) + + +@router.post("/reset-password") +async def reset_post(request: Request, db: Session = Depends(get_db)): + token = request.query_params.get("token", "") + form = await request.form() + password = str(form.get("password", "")) + password_confirm = str(form.get("password_confirm", "")) + errors = [] + + user = db.query(User).filter(User.password_reset_token == token).first() + if not user or not user.password_reset_expires or user.password_reset_expires < datetime.utcnow(): + return _render(request, "message.html", { + "user": None, "title": "Ссылка недействительна", + "message": "Ссылка для сброса пароля устарела.", + "link": "/forgot-password", "link_text": "Запросить новую ссылку", + }) + + if len(password) < 8: + errors.append("Пароль должен содержать минимум 8 символов") + if password != password_confirm: + errors.append("Пароли не совпадают") + + if errors: + return _render(request, "reset_password.html", { + "user": None, "token": token, "errors": errors, + }) + + user.password_hash = hash_password(password) + user.password_reset_token = None + user.password_reset_expires = None + db.commit() + + return _render(request, "message.html", { + "user": None, "title": "Пароль изменён", + "message": "Ваш пароль успешно изменён.", + "link": "/login", "link_text": "Войти", + }) diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..c530339 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,431 @@ +/* Brand colors */ +:root { + --pico-primary: #F05023; + --pico-primary-hover: #d44420; + --pico-primary-focus: rgba(240, 80, 35, 0.25); + --pico-primary-inverse: #fff; + --brand-primary: #F05023; + --brand-secondary: #0986E2; + --brand-secondary-hover: #0770c0; +} + +/* Header / nav */ +.site-header { + background: #fff; + border-bottom: 2px solid var(--brand-primary); + padding: 0; + margin-bottom: 0; +} + +.site-header nav { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + gap: 1rem; +} + +.site-header nav > ul { + margin: 0; + padding: 0; + list-style: none; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.brand-logo { + font-size: 1.3rem; + font-weight: 700; + color: var(--brand-primary) !important; + text-decoration: none; +} + +.nav-links { + flex: 1; + justify-content: flex-end; +} + +.nav-links a { + color: var(--pico-color); + text-decoration: none; + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +.nav-links a:hover { + color: var(--brand-primary); +} + +.nav-links a.secondary { + color: var(--pico-muted-color); +} + +.mobile-menu { + display: none; +} + +.mobile-menu summary { + padding: 0.25rem 0.5rem; + font-size: 1.25rem; +} + +.mobile-menu > ul { + position: absolute; + right: 1rem; + background: var(--pico-background-color); + border: 1px solid var(--pico-border-color); + border-radius: var(--pico-border-radius); + padding: 0.5rem 0; + list-style: none; + margin: 0; + z-index: 100; + min-width: 180px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); +} + +.mobile-menu > ul li a { + display: block; + padding: 0.5rem 1rem; + text-decoration: none; + color: var(--pico-color); +} + +.mobile-menu > ul li a:hover { + background: var(--pico-muted-background-color); +} + +@media (max-width: 768px) { + .nav-links { display: none; } + .mobile-menu { display: block; } +} + +/* Page spacing */ +.py-4 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +/* Alerts */ +.alert { + border-radius: var(--pico-border-radius); + padding: 0.75rem 1rem; + margin-bottom: 1rem; +} + +.alert p { margin: 0; } +.alert p + p { margin-top: 0.25rem; } + +.alert-danger { + background: #fef2f2; + border: 1px solid #fecaca; + color: #b91c1c; +} + +.alert-success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #15803d; +} + +.alert-warning { + background: #fffbeb; + border: 1px solid #fde68a; + color: #b45309; +} + +/* Cards (using
) */ +article.card { + margin: 0; + padding: 0; + overflow: hidden; +} + +article.card > header { + padding: 0.75rem 1rem; + background: var(--pico-muted-background-color); + border-bottom: 1px solid var(--pico-border-color); + margin: 0; +} + +article.card > header h1, +article.card > header h2, +article.card > header h5 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +article.card > .card-body { + padding: 1.25rem; +} + +article.card > footer { + padding: 0.75rem 1rem; + background: var(--pico-muted-background-color); + border-top: 1px solid var(--pico-border-color); + margin: 0; +} + +/* List groups */ +.list-group { + list-style: none; + padding: 0; + margin: 0; +} + +.list-group-item { + padding: 0.6rem 1rem; + border-bottom: 1px solid var(--pico-border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.list-group-item:last-child { border-bottom: none; } + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + line-height: 1; +} + +.badge-success { background: #dcfce7; color: #15803d; } +.badge-danger { background: #fee2e2; color: #b91c1c; } +.badge-warning { background: #fef3c7; color: #b45309; } +.badge-secondary { background: var(--pico-muted-background-color); color: var(--pico-muted-color); } +.badge-light { background: var(--pico-muted-background-color); color: var(--pico-muted-color); border: 1px solid var(--pico-border-color); } + +/* Buttons */ +button.secondary, a[role="button"].secondary { + --pico-background-color: var(--brand-secondary); + --pico-border-color: var(--brand-secondary); + --pico-color: #fff; +} + +button.outline.danger, a[role="button"].outline.danger { + --pico-color: #dc2626; + --pico-border-color: #dc2626; +} + +button.danger, a[role="button"].danger { + --pico-background-color: #dc2626; + --pico-border-color: #dc2626; + --pico-color: #fff; +} + +button.sm, a[role="button"].sm { + padding: 0.25rem 0.6rem; + font-size: 0.875rem; +} + +/* Layout helpers */ +.row { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.col { flex: 1 1 0; } +.col-auto { flex: 0 0 auto; } + +.col-sm-6 { flex: 0 0 calc(50% - 0.5rem); min-width: 200px; } +.col-sm-10 { flex: 0 0 calc(83.33% - 0.5rem); } +.col-md-6 { flex: 0 0 calc(50% - 0.5rem); } +.col-md-7 { flex: 0 0 calc(58.33% - 0.5rem); } +.col-lg-4 { flex: 0 0 calc(33.33% - 0.67rem); } +.col-lg-5 { flex: 0 0 calc(41.67% - 0.5rem); } +.col-lg-6 { flex: 0 0 calc(50% - 0.5rem); } +.col-12 { flex: 0 0 100%; } +.col-md-6-auto { flex: 0 0 calc(50% - 0.5rem); } + +@media (max-width: 768px) { + .col-sm-6, .col-md-6, .col-md-7, .col-lg-4, .col-lg-5, .col-lg-6, .col-md-6-auto { flex: 0 0 100%; } +} + +.justify-center { justify-content: center; } +.justify-between { justify-content: space-between; } +.justify-end { justify-content: flex-end; } +.align-center { align-items: center; } +.flex-wrap { flex-wrap: wrap; } +.flex-col { flex-direction: column; } +.flex-1 { flex: 1; } +.flex-fill { flex: 1 1 0; } + +.gap-1 { gap: 0.25rem; } +.gap-2 { gap: 0.5rem; } +.gap-3 { gap: 0.75rem; } + +.mt-2 { margin-top: 0.5rem; } +.mt-3 { margin-top: 0.75rem; } +.mt-4 { margin-top: 1.5rem; } +.mt-5 { margin-top: 3rem; } +.mb-0 { margin-bottom: 0; } +.mb-1 { margin-bottom: 0.25rem; } +.mb-2 { margin-bottom: 0.5rem; } +.mb-3 { margin-bottom: 0.75rem; } +.mb-4 { margin-bottom: 1.5rem; } +.ms-auto { margin-left: auto; } +.me-1 { margin-right: 0.25rem; } +.me-2 { margin-right: 0.5rem; } +.me-3 { margin-right: 0.75rem; } + +.d-flex { display: flex; } +.d-grid { display: grid; } +.d-none { display: none; } +.d-block { display: block; } + +.text-center { text-align: center; } +.text-end { text-align: right; } +.text-muted { color: var(--pico-muted-color); } +.small { font-size: 0.875rem; } +.fs-1 { font-size: 2rem; } +.fs-2 { font-size: 1.5rem; } +.fs-5 { font-size: 1.15rem; } +.fs-6 { font-size: 0.875rem; } + +.text-success { color: #15803d; } +.text-danger { color: #dc2626; } +.text-warning { color: #b45309; } +.text-primary { color: var(--brand-primary); } +.text-secondary { color: var(--brand-secondary); } +.text-white { color: #fff; } + +.bg-danger-header { + background: #dc2626; + color: #fff; +} + +.font-monospace { font-family: monospace; } +.w-100 { width: 100%; } +.h-100 { height: 100%; } + +/* Table */ +.table-scroll { + overflow-x: auto; +} + +table.align-middle td, +table.align-middle th { + vertical-align: middle; +} + +/* Breadcrumb */ +.breadcrumb { + display: flex; + align-items: center; + gap: 0.25rem; + list-style: none; + padding: 0; + margin: 0 0 1rem; + font-size: 0.9rem; + color: var(--pico-muted-color); +} + +.breadcrumb-item + .breadcrumb-item::before { + content: "/"; + margin-right: 0.25rem; + color: var(--pico-muted-color); +} + +.breadcrumb-item.active { color: var(--pico-color); } + +/* Dropdown */ +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown-menu { + display: none; + position: absolute; + right: 0; + top: calc(100% + 4px); + background: var(--pico-background-color); + border: 1px solid var(--pico-border-color); + border-radius: var(--pico-border-radius); + box-shadow: 0 4px 12px rgba(0,0,0,0.12); + z-index: 200; + min-width: 220px; + padding: 0.25rem 0; + list-style: none; + margin: 0; +} + +.dropdown.open .dropdown-menu { display: block; } + +.dropdown-item { + display: block; + width: 100%; + padding: 0.45rem 1rem; + background: none; + border: none; + text-align: left; + cursor: pointer; + color: var(--pico-color); + font-size: 0.9rem; + text-decoration: none; +} + +.dropdown-item:hover { + background: var(--pico-muted-background-color); +} + +.dropdown-item.muted { color: var(--pico-muted-color); } + +.dropdown-divider { + border: none; + border-top: 1px solid var(--pico-border-color); + margin: 0.25rem 0; +} + +/* Spinner */ +.spinner { + display: inline-block; + width: 2rem; + height: 2rem; + border: 3px solid var(--pico-muted-background-color); + border-top-color: var(--brand-primary); + border-radius: 50%; + animation: spin 0.75s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* Input group */ +.input-group { + display: flex; + gap: 0; +} + +.input-group input { + border-radius: var(--pico-border-radius) 0 0 var(--pico-border-radius); + margin: 0; + flex: 1; +} + +.input-group button { + border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0; + margin: 0; + white-space: nowrap; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--pico-muted-color); +} + +.empty-state .empty-icon { + font-size: 3rem; + display: block; + margin-bottom: 0.75rem; +} diff --git a/web/tasks/celery_app.py b/web/tasks/celery_app.py index fbd4062..eb5671a 100644 --- a/web/tasks/celery_app.py +++ b/web/tasks/celery_app.py @@ -18,5 +18,6 @@ celery_app.conf.update( "web.tasks.sync.*": {"queue": "sync"}, "web.tasks.health.*": {"queue": "health"}, "web.tasks.catalog.*": {"queue": "default"}, + "web.notifications.tasks.*": {"queue": "notifications"}, }, ) diff --git a/web/templates/admin/roles.html b/web/templates/admin/roles.html new file mode 100644 index 0000000..13a87e5 --- /dev/null +++ b/web/templates/admin/roles.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Роли и права — ЭВОСИНК{% endblock %} + +{% block content %} + + +

Роли и права

+ +{% for role in roles %} +
+
+

{{ role.name }} + — {{ role.description or '' }} +

+
+
+
+
+ {% for perm in permissions %} +
+ +
+ {% endfor %} +
+ +
+
+
+{% endfor %} +{% endblock %} diff --git a/web/templates/admin/user_detail.html b/web/templates/admin/user_detail.html new file mode 100644 index 0000000..25c6523 --- /dev/null +++ b/web/templates/admin/user_detail.html @@ -0,0 +1,147 @@ +{% extends "base.html" %} +{% block title %}{{ target.first_name }} {{ target.last_name }} — Админ — ЭВОСИНК{% endblock %} + +{% block content %} + + +{% if request.query_params.get('success') == 'reset_sent' %} +

Ссылка для сброса пароля отправлена.

+{% elif request.query_params.get('success') == 'invite_sent' %} +

Приглашение отправлено.

+{% elif request.query_params.get('success') == 'saved' %} +

Данные сохранены.

+{% endif %} + +
+
+
+

Профиль

+
    +
  • ID{{ target.id }}
  • +
  • Имя{{ target.first_name }} {{ target.last_name }}
  • +
  • Email + {{ target.email }} + {% if target.is_email_confirmed %} + подтверждён + {% else %} + не подтверждён + {% endif %} + +
  • +
  • Телефон{{ target.phone }}
  • +
  • Роль + + {% if target.role == 'system' %}Системный + {% elif target.role == 'admin' %}Администратор + {% else %}Пользователь + {% endif %} + +
  • +
  • Статус + + {% if target.status == 'active' %}Активен + {% elif target.status == 'pending' %}Ожидает + {% else %}Заблокирован + {% endif %} + +
  • +
  • Регистрация{{ target.created_at | datefmt }}
  • + {% if target.evotor_user_id %} +
  • Эвотор ID{{ target.evotor_user_id }}
  • + {% endif %} + {% if target.invite_token %} +
  • Приглашение до{{ target.invite_expires | datefmt }}
  • + {% endif %} +
+
+ + {% if target.evotor_meta %} +
+

Данные Эвотор

+
+
{{ target.evotor_meta | tojson(indent=2) }}
+
+
+ {% endif %} +
+ +
+
+

Действия

+
+ {% if target.status != 'active' %} +
+ +
+ {% endif %} + {% if target.status != 'suspended' %} +
+ +
+ {% endif %} +
+ +
+
+ +
+ {% if user.role == 'system' and target.id != user.id %} +
+ +
+ {% endif %} +
+
+ +
+

Редактировать

+
+
+
+
+ +
+
+ +
+
+ + + {% if user.role == 'system' %} + + {% endif %} + +
+
+
+
+
+{% endblock %} diff --git a/web/templates/admin/users.html b/web/templates/admin/users.html new file mode 100644 index 0000000..3a664cf --- /dev/null +++ b/web/templates/admin/users.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} +{% block title %}Пользователи — Администрирование — ЭВОСИНК{% endblock %} + +{% block content %} +
+

Пользователи

+ Всего: {{ total }} +
+ +
+
+
+ + + + + {% if search or status_filter or role_filter %} + Сбросить + {% endif %} +
+
+
+ +
+
+ + + + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDИмяEmailТелефонРольСтатусЭвоторРегистрация
{{ u.id }}{{ u.first_name }} {{ u.last_name }} + {{ u.email }} + {% if not u.is_email_confirmed %} + + {% endif %} + {{ u.phone }} + {% if u.role == 'system' %}Системный + {% elif u.role == 'admin' %}Админ + {% else %}Польз. + {% endif %} + + {% if u.status == 'active' %}Активен + {% elif u.status == 'pending' %}Ожидает + {% else %}Заблок. + {% endif %} + + {% if u.evotor_user_id %} + + {% else %} + + {% endif %} + {{ u.created_at | datefmt }} + + + +
Пользователи не найдены
+
+ {% if total_pages > 1 %} +
+
+ {% if page > 1 %} + « + {% endif %} + Стр. {{ page }} из {{ total_pages }} + {% if page < total_pages %} + » + {% endif %} +
+
+ {% endif %} +
+ +{% if user.role == 'system' %} + +{% endif %} +{% endblock %} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..88f20e5 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,106 @@ + + + + + + {% block title %}ЭВОСИНК{% endblock %} + + + + + + + +
+ {% if errors %} + + {% endif %} + + {% if success %} + + {% endif %} + + {% block content %}{% endblock %} +
+ + {% if jivosite_widget_id %} + + {% endif %} + + + + {% block scripts %}{% endblock %} + + diff --git a/web/templates/confirm_email.html b/web/templates/confirm_email.html new file mode 100644 index 0000000..d912a54 --- /dev/null +++ b/web/templates/confirm_email.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Подтверждение email — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+ +

Подтвердите ваш email

+

Проверьте почту и нажмите на ссылку для подтверждения.

+
+
+
+
+{% endblock %} diff --git a/web/templates/email_confirmed.html b/web/templates/email_confirmed.html new file mode 100644 index 0000000..f51283d --- /dev/null +++ b/web/templates/email_confirmed.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Email подтвержден — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+ +

Email подтвержден!

+

Ваш email успешно подтвержден. Теперь вы можете войти в систему.

+ Войти +
+
+
+
+{% endblock %} diff --git a/web/templates/forgot_password.html b/web/templates/forgot_password.html new file mode 100644 index 0000000..0e115e6 --- /dev/null +++ b/web/templates/forgot_password.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Забыли пароль — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+

Забыли пароль?

+

Введите email, указанный при регистрации.

+
+ + +
+ +
+
+
+
+{% endblock %} diff --git a/web/templates/invite.html b/web/templates/invite.html new file mode 100644 index 0000000..ba05225 --- /dev/null +++ b/web/templates/invite.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Завершение регистрации — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+

Добро пожаловать в ЭВОСИНК!

+
+
+

Ваш аккаунт был создан через Эвотор. Заполните данные профиля и задайте пароль для входа.

+
+
+
+ +
+
+ +
+
+ + + + + +
+
+
+
+
+{% endblock %} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..9984470 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Вход — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+ +
+
+{% endblock %} diff --git a/web/templates/message.html b/web/templates/message.html new file mode 100644 index 0000000..fdd483c --- /dev/null +++ b/web/templates/message.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}{{ title }} — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+

{{ title }}

+

{{ message }}

+ {% if link %} + {{ link_text }} + {% endif %} +
+
+
+
+{% endblock %} diff --git a/web/templates/profile_change_password.html b/web/templates/profile_change_password.html new file mode 100644 index 0000000..3dfd26b --- /dev/null +++ b/web/templates/profile_change_password.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Изменить пароль — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+

Изменить пароль

+
+
+
+ + + +
+ + Отмена +
+
+
+
+
+
+{% endblock %} diff --git a/web/templates/profile_delete.html b/web/templates/profile_delete.html new file mode 100644 index 0000000..9fa1b58 --- /dev/null +++ b/web/templates/profile_delete.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+

Удалить аккаунт

+
+
+ +
+ +
+ + Отмена +
+
+
+
+
+
+{% endblock %} diff --git a/web/templates/profile_edit.html b/web/templates/profile_edit.html new file mode 100644 index 0000000..f42506f --- /dev/null +++ b/web/templates/profile_edit.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+

Редактировать профиль

+
+
+
+
+
+ +
+
+ +
+
+ + +
+ + Отмена +
+
+
+
+
+
+{% endblock %} diff --git a/web/templates/profile_view.html b/web/templates/profile_view.html new file mode 100644 index 0000000..175ad4b --- /dev/null +++ b/web/templates/profile_view.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block title %}Личный кабинет — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+

Личный кабинет

+
+
    +
  • + Имя + {{ user.first_name }} +
  • +
  • + Фамилия + {{ user.last_name }} +
  • +
  • + Email + + {{ user.email }} + {% if user.is_email_confirmed %} + подтверждён + {% else %} + не подтверждён + {% endif %} + +
  • +
  • + Телефон + {{ user.phone }} +
  • +
  • + Роль + + {% if user.role == 'system' %}Системный + {% elif user.role == 'admin' %}Администратор + {% else %}Пользователь + {% endif %} + +
  • +
  • + Статус + + {% if user.status == 'active' %}Активен + {% elif user.status == 'pending' %}Ожидает подтверждения + {% else %}Заблокирован + {% endif %} + +
  • + {% if user.evotor_user_id %} +
  • + Эвотор ID + {{ user.evotor_user_id }} +
  • + {% endif %} +
  • + Регистрация + {{ user.created_at | datefmt }} +
  • +
+ +
+
+
+{% endblock %} diff --git a/web/templates/register.html b/web/templates/register.html new file mode 100644 index 0000000..306a79c --- /dev/null +++ b/web/templates/register.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Регистрация — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+

Регистрация

+
+
+
+ +
+
+ +
+
+ + + + + +
+ +
+
+
+
+{% endblock %} diff --git a/web/templates/reset_password.html b/web/templates/reset_password.html new file mode 100644 index 0000000..845a9a8 --- /dev/null +++ b/web/templates/reset_password.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Новый пароль — ЭВОСИНК{% endblock %} + +{% block content %} +
+
+
+
+

Новый пароль

+
+ + + +
+
+
+
+
+{% endblock %} diff --git a/web/templates_env.py b/web/templates_env.py new file mode 100644 index 0000000..76f523c --- /dev/null +++ b/web/templates_env.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from fastapi.templating import Jinja2Templates + +templates = Jinja2Templates(directory="web/templates") + + +def _datefmt(value: datetime | None, fmt: str = "%d.%m.%Y %H:%M") -> str: + if value is None: + return "—" + return value.strftime(fmt) + + +def _price(value) -> str: + if value is None: + return "—" + return f"{float(value):,.2f} ₽".replace(",", " ") + + +templates.env.filters["datefmt"] = _datefmt +templates.env.filters["price"] = _price