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 '' }}
+
+
+
+
+{% 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 %}
+
+
+
+
+
+
+
+
+
+{% 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 total_pages > 1 %}
+
+ {% 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 %}
+
+ {% for error in errors %}
+
{{ error }}
+ {% endfor %}
+
+ {% 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