feat: Evotor user lifecycle, RBAC, admin panel

- Receive Evotor webhooks: POST /user/create, /user/verify, /user/token
- Create users in pending status; match to existing users by email/phone
- Send invite link via Celery notification task; user sets password at /invite
- Abstract EmailProvider/SMSProvider with ConsoleEmailProvider default
- Role-based access control: role enum on users + roles/permissions tables
- Admin panel: /admin/users (list, filter, search, paginate), user detail card
  with activate/suspend/reset-password/send-invite/edit/delete actions
- Admin roles management: /admin/roles with per-role permission assignment
- Extend user profile card: role, status, Evotor ID, email confirmation badge
- Auth routes: register, login, logout, confirm-email, forgot/reset password
- Alembic migrations 0002 (full schema + new fields) and 0003 (RBAC + seeds)
- Port Pico CSS + Bootstrap Icons UI from Node.js commit (854c912)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-04-28 12:01:25 +03:00
parent ba34adbbcf
commit 5ead89e0cf
44 changed files with 3101 additions and 3 deletions

View File

@@ -73,7 +73,7 @@ services:
condition: service_healthy condition: service_healthy
db: db:
condition: service_healthy 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: beat:
build: build:

0
web/auth/__init__.py Normal file
View File

11
web/auth/password.py Normal file
View File

@@ -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)

35
web/auth/rbac.py Normal file
View File

@@ -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)

25
web/auth/session.py Normal file
View File

@@ -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)

View File

@@ -16,6 +16,11 @@ class Settings(BaseSettings):
VK_API_VERSION: str = "5.199" VK_API_VERSION: str = "5.199"
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600 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_USER: str = "admin"
FLOWER_PASSWORD: str = "changeme" FLOWER_PASSWORD: str = "changeme"

View File

@@ -1,6 +1,9 @@
import logging 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: try:
from pythonjsonlogger import jsonlogger from pythonjsonlogger import jsonlogger
@@ -11,9 +14,65 @@ except ImportError:
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logging.root.setLevel(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") @app.get("/health")
async def health(): async def health():
return {"status": "ok"} 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)

View File

@@ -0,0 +1,238 @@
"""users and connections schema
Revision ID: 0002
Revises: 0001
Create Date: 2026-04-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# ── users ────────────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
phone VARCHAR(20) NOT NULL,
password_hash VARCHAR(255) NULL,
is_email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
email_confirm_token VARCHAR(255) NULL,
password_reset_token VARCHAR(255) NULL,
password_reset_expires DATETIME NULL,
role ENUM('system','admin','user') NOT NULL DEFAULT 'user',
status ENUM('pending','active','suspended') NOT NULL DEFAULT 'pending',
evotor_user_id VARCHAR(255) NULL,
evotor_meta JSON NULL,
invite_token VARCHAR(255) NULL,
invite_expires DATETIME NULL,
phone_otp VARCHAR(10) NULL,
phone_otp_expires DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT ix_users_email UNIQUE (email),
CONSTRAINT ix_users_phone UNIQUE (phone),
CONSTRAINT ix_users_evotor_user_id UNIQUE (evotor_user_id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
# Add new columns if migrating from the Node.js schema (which lacked them)
for col_sql in [
"ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(255) NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role ENUM('system','admin','user') NOT NULL DEFAULT 'user'",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('pending','active','suspended') NOT NULL DEFAULT 'pending'",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS evotor_user_id VARCHAR(255) NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS evotor_meta JSON NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_token VARCHAR(255) NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_expires DATETIME NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_otp VARCHAR(10) NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_otp_expires DATETIME NULL",
]:
try:
conn.execute(sa.text(col_sql))
except Exception:
pass # column/constraint already exists
# Add unique index on evotor_user_id if missing
try:
conn.execute(sa.text(
"ALTER TABLE users ADD CONSTRAINT ix_users_evotor_user_id UNIQUE (evotor_user_id)"
))
except Exception:
pass
# Add role/status indexes if missing
for idx_sql in [
"CREATE INDEX IF NOT EXISTS ix_users_role ON users (role)",
"CREATE INDEX IF NOT EXISTS ix_users_status ON users (status)",
]:
try:
conn.execute(sa.text(idx_sql))
except Exception:
pass
# ── evotor_connections ───────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS evotor_connections (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
evotor_user_id VARCHAR(255) NULL,
access_token TEXT NOT NULL,
api_token VARCHAR(255) NULL,
store_id VARCHAR(255) NULL,
store_name VARCHAR(255) NULL,
refresh_token TEXT NULL,
token_expires_at DATETIME NULL,
is_online BOOLEAN NOT NULL DEFAULT FALSE,
last_checked_at DATETIME NULL,
connected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT ix_evotor_connections_user_id UNIQUE (user_id),
CONSTRAINT ix_evotor_connections_evotor_user_id UNIQUE (evotor_user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
try:
conn.execute(sa.text(
"ALTER TABLE evotor_connections ADD COLUMN IF NOT EXISTS api_token VARCHAR(255) NULL"
))
except Exception:
pass
# ── vk_connections ───────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS vk_connections (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
access_token TEXT NOT NULL,
vk_user_id VARCHAR(50) NULL,
first_name VARCHAR(255) NULL,
last_name VARCHAR(255) NULL,
is_online BOOLEAN NOT NULL DEFAULT FALSE,
last_checked_at DATETIME NULL,
connected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT ix_vk_connections_user_id UNIQUE (user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
# ── sync_configs ─────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS sync_configs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
confirmed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT ix_sync_configs_user_id UNIQUE (user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
# ── sync_filters ─────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS sync_filters (
id INT AUTO_INCREMENT PRIMARY KEY,
sync_config_id INT NOT NULL,
entity_type VARCHAR(20) NOT NULL,
entity_id VARCHAR(255) NOT NULL,
entity_name VARCHAR(255) NULL,
filter_mode VARCHAR(10) NOT NULL,
parent_entity_id VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_sync_filters_config_type_entity UNIQUE (sync_config_id, entity_type, entity_id),
FOREIGN KEY (sync_config_id) REFERENCES sync_configs(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
# ── cached_stores ────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS cached_stores (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
evotor_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
address VARCHAR(500) NULL,
fetched_at DATETIME NOT NULL,
CONSTRAINT uq_cached_stores_user_evotor UNIQUE (user_id, evotor_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
try:
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_cached_stores_user_id ON cached_stores (user_id)"))
except Exception:
pass
# ── cached_groups ────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS cached_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
evotor_id VARCHAR(255) NOT NULL,
store_evotor_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
fetched_at DATETIME NOT NULL,
CONSTRAINT uq_cached_groups_user_evotor UNIQUE (user_id, evotor_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
try:
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_cached_groups_user_store ON cached_groups (user_id, store_evotor_id)"
))
except Exception:
pass
# ── cached_products ──────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS cached_products (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
evotor_id VARCHAR(255) NOT NULL,
store_evotor_id VARCHAR(255) NOT NULL,
group_evotor_id VARCHAR(255) NULL,
name VARCHAR(255) NOT NULL,
price DECIMAL(12,2) NULL,
quantity DECIMAL(12,3) NULL,
measure_name VARCHAR(20) NULL,
article_number VARCHAR(100) NULL,
allow_to_sell BOOLEAN NULL,
fetched_at DATETIME NOT NULL,
synced_at DATETIME NULL,
CONSTRAINT uq_cached_products_user_evotor UNIQUE (user_id, evotor_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
try:
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_cached_products_user_store_group "
"ON cached_products (user_id, store_evotor_id, group_evotor_id)"
))
except Exception:
pass
def downgrade() -> None:
conn = op.get_bind()
for table in [
"cached_products", "cached_groups", "cached_stores",
"sync_filters", "sync_configs",
"vk_connections", "evotor_connections",
"users",
]:
conn.execute(sa.text(f"DROP TABLE IF EXISTS {table}"))

View File

@@ -0,0 +1,105 @@
"""RBAC tables with default roles and permissions
Revision ID: 0003
Revises: 0002
Create Date: 2026-04-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0003"
down_revision: Union[str, None] = "0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
DEFAULT_ROLES = [
("system", "Системный администратор — полный доступ"),
("admin", "Администратор — управление пользователями"),
("user", "Обычный пользователь"),
]
DEFAULT_PERMISSIONS = [
("admin.users.view", "Просмотр списка пользователей"),
("admin.users.edit", "Редактирование пользователей"),
("admin.users.delete", "Удаление пользователей"),
("admin.roles.manage", "Управление ролями и правами"),
]
# system gets all permissions; admin gets view+edit
ROLE_PERMISSION_MAP = {
"system": ["admin.users.view", "admin.users.edit", "admin.users.delete", "admin.roles.manage"],
"admin": ["admin.users.view", "admin.users.edit"],
"user": [],
}
def upgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
description VARCHAR(255) NULL,
CONSTRAINT uq_roles_name UNIQUE (name)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description VARCHAR(255) NULL,
CONSTRAINT uq_permissions_name UNIQUE (name)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
# Seed default roles
for name, description in DEFAULT_ROLES:
conn.execute(sa.text(
"INSERT IGNORE INTO roles (name, description) VALUES (:name, :desc)"
), {"name": name, "desc": description})
# Seed default permissions
for name, description in DEFAULT_PERMISSIONS:
conn.execute(sa.text(
"INSERT IGNORE INTO permissions (name, description) VALUES (:name, :desc)"
), {"name": name, "desc": description})
# Seed role_permissions
for role_name, perm_names in ROLE_PERMISSION_MAP.items():
for perm_name in perm_names:
conn.execute(sa.text("""
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.name = :role AND p.name = :perm
"""), {"role": role_name, "perm": perm_name})
def downgrade() -> None:
conn = op.get_bind()
for table in ["user_roles", "role_permissions", "permissions", "roles"]:
conn.execute(sa.text(f"DROP TABLE IF EXISTS {table}"))

View File

@@ -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,
)

136
web/models/connections.py Normal file
View File

@@ -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"),
)

34
web/models/rbac.py Normal file
View File

@@ -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)

50
web/models/user.py Normal file
View File

@@ -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"),
)

View File

11
web/notifications/base.py Normal file
View File

@@ -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: ...

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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)

0
web/routes/__init__.py Normal file
View File

271
web/routes/admin.py Normal file
View File

@@ -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'<p>Сброс пароля (запрошен администратором): <a href="{reset_url}">{reset_url}</a></p>'
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"<p>Вам отправлено приглашение в ЭВОСИНК.</p>"
f'<p><a href="{invite_url}">{invite_url}</a></p>'
f"<p>Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.</p>"
)
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)

170
web/routes/auth.py Normal file
View File

@@ -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'<p>Подтвердите email: <a href="{confirm_url}">{confirm_url}</a></p>'
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'<p>Подтвердите email: <a href="{confirm_url}">{confirm_url}</a></p>'
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)

View File

@@ -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"<p>Здравствуйте!</p>"
f"<p>Вам открыт доступ к ЭВОСИНК. Завершите регистрацию по ссылке:</p>"
f'<p><a href="{invite_url}">{invite_url}</a></p>'
f"<p>Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.</p>"
)
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({})

99
web/routes/invite.py Normal file
View File

@@ -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": "Войти",
})

143
web/routes/profile.py Normal file
View File

@@ -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)

101
web/routes/reset.py Normal file
View File

@@ -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'<p>Сброс пароля: <a href="{reset_url}">{reset_url}</a></p>'
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": "Войти",
})

431
web/static/style.css Normal file
View File

@@ -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>) */
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;
}

View File

@@ -18,5 +18,6 @@ celery_app.conf.update(
"web.tasks.sync.*": {"queue": "sync"}, "web.tasks.sync.*": {"queue": "sync"},
"web.tasks.health.*": {"queue": "health"}, "web.tasks.health.*": {"queue": "health"},
"web.tasks.catalog.*": {"queue": "default"}, "web.tasks.catalog.*": {"queue": "default"},
"web.notifications.tasks.*": {"queue": "notifications"},
}, },
) )

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Роли и права — ЭВОСИНК{% endblock %}
{% block content %}
<nav class="breadcrumb">
<li class="breadcrumb-item"><a href="/admin/users">Пользователи</a></li>
<li class="breadcrumb-item active">Роли и права</li>
</nav>
<h1 style="font-size:1.3rem;" class="mb-3"><i class="bi bi-shield-lock me-2"></i>Роли и права</h1>
{% for role in roles %}
<article class="card mb-3">
<header>
<h2 style="font-size:1rem;">{{ role.name }}
<span class="text-muted small fw-normal">— {{ role.description or '' }}</span>
</h2>
</header>
<div class="card-body">
<form method="post" action="/admin/roles/{{ role.id }}/permissions">
<div class="row gap-2 flex-wrap">
{% for perm in permissions %}
<div class="col-auto">
<label style="display:flex; align-items:center; gap:0.4rem; margin:0;">
<input type="checkbox" name="perm_{{ perm.id }}" value="{{ perm.id }}"
{% if perm.id in role_perm_ids[role.id] %}checked{% endif %}>
{{ perm.name }}
{% if perm.description %}
<span class="text-muted small">({{ perm.description }})</span>
{% endif %}
</label>
</div>
{% endfor %}
</div>
<button type="submit" class="sm mt-3">Сохранить права для «{{ role.name }}»</button>
</form>
</div>
</article>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}{{ target.first_name }} {{ target.last_name }} — Админ — ЭВОСИНК{% endblock %}
{% block content %}
<nav class="breadcrumb">
<li class="breadcrumb-item"><a href="/admin/users">Пользователи</a></li>
<li class="breadcrumb-item active">{{ target.first_name }} {{ target.last_name }}</li>
</nav>
{% if request.query_params.get('success') == 'reset_sent' %}
<div class="alert alert-success mb-3"><p>Ссылка для сброса пароля отправлена.</p></div>
{% elif request.query_params.get('success') == 'invite_sent' %}
<div class="alert alert-success mb-3"><p>Приглашение отправлено.</p></div>
{% elif request.query_params.get('success') == 'saved' %}
<div class="alert alert-success mb-3"><p>Данные сохранены.</p></div>
{% endif %}
<div class="row gap-3 align-start">
<div class="col-lg-6">
<article class="card">
<header><h2>Профиль</h2></header>
<ul class="list-group">
<li class="list-group-item"><span class="text-muted small">ID</span><span>{{ target.id }}</span></li>
<li class="list-group-item"><span class="text-muted small">Имя</span><span>{{ target.first_name }} {{ target.last_name }}</span></li>
<li class="list-group-item"><span class="text-muted small">Email</span>
<span>{{ target.email }}
{% if target.is_email_confirmed %}
<span class="badge badge-success ms-1">подтверждён</span>
{% else %}
<span class="badge badge-warning ms-1">не подтверждён</span>
{% endif %}
</span>
</li>
<li class="list-group-item"><span class="text-muted small">Телефон</span><span>{{ target.phone }}</span></li>
<li class="list-group-item"><span class="text-muted small">Роль</span>
<span>
{% if target.role == 'system' %}<span class="badge badge-danger">Системный</span>
{% elif target.role == 'admin' %}<span class="badge badge-warning">Администратор</span>
{% else %}<span class="badge badge-secondary">Пользователь</span>
{% endif %}
</span>
</li>
<li class="list-group-item"><span class="text-muted small">Статус</span>
<span>
{% if target.status == 'active' %}<span class="badge badge-success">Активен</span>
{% elif target.status == 'pending' %}<span class="badge badge-warning">Ожидает</span>
{% else %}<span class="badge badge-danger">Заблокирован</span>
{% endif %}
</span>
</li>
<li class="list-group-item"><span class="text-muted small">Регистрация</span><span>{{ target.created_at | datefmt }}</span></li>
{% if target.evotor_user_id %}
<li class="list-group-item"><span class="text-muted small">Эвотор ID</span><span class="font-monospace small">{{ target.evotor_user_id }}</span></li>
{% endif %}
{% if target.invite_token %}
<li class="list-group-item"><span class="text-muted small">Приглашение до</span><span>{{ target.invite_expires | datefmt }}</span></li>
{% endif %}
</ul>
</article>
{% if target.evotor_meta %}
<article class="card mt-3">
<header><h2>Данные Эвотор</h2></header>
<div class="card-body">
<pre class="font-monospace small" style="overflow-x:auto; white-space:pre-wrap; margin:0;">{{ target.evotor_meta | tojson(indent=2) }}</pre>
</div>
</article>
{% endif %}
</div>
<div class="col-lg-6">
<article class="card">
<header><h2>Действия</h2></header>
<div class="card-body d-grid gap-2">
{% if target.status != 'active' %}
<form method="post" action="/admin/users/{{ target.id }}/activate">
<button type="submit" class="w-100">
<i class="bi bi-check-circle me-1"></i>Активировать
</button>
</form>
{% endif %}
{% if target.status != 'suspended' %}
<form method="post" action="/admin/users/{{ target.id }}/suspend">
<button type="submit" class="w-100 outline danger">
<i class="bi bi-slash-circle me-1"></i>Заблокировать
</button>
</form>
{% endif %}
<form method="post" action="/admin/users/{{ target.id }}/reset-password">
<button type="submit" class="w-100 outline secondary">
<i class="bi bi-key me-1"></i>Сбросить пароль
</button>
</form>
<form method="post" action="/admin/users/{{ target.id }}/send-invite">
<button type="submit" class="w-100 outline secondary">
<i class="bi bi-envelope me-1"></i>Отправить приглашение
</button>
</form>
{% if user.role == 'system' and target.id != user.id %}
<form method="post" action="/admin/users/{{ target.id }}/delete"
onsubmit="return confirm('Удалить пользователя {{ target.email }}? Это действие необратимо.')">
<button type="submit" class="w-100 danger sm">
<i class="bi bi-trash me-1"></i>Удалить
</button>
</form>
{% endif %}
</div>
</article>
<article class="card mt-3">
<header><h2>Редактировать</h2></header>
<div class="card-body">
<form method="post" action="/admin/users/{{ target.id }}/edit">
<div class="row gap-2 mb-2">
<div class="col">
<label for="first_name">Имя
<input type="text" id="first_name" name="first_name" value="{{ target.first_name }}" required>
</label>
</div>
<div class="col">
<label for="last_name">Фамилия
<input type="text" id="last_name" name="last_name" value="{{ target.last_name }}" required>
</label>
</div>
</div>
<label for="email">Email
<input type="email" id="email" name="email" value="{{ target.email }}">
</label>
<label for="phone">Телефон
<input type="tel" id="phone" name="phone" value="{{ target.phone }}">
</label>
{% if user.role == 'system' %}
<label for="role">Роль
<select id="role" name="role">
<option value="user" {% if target.role == 'user' %}selected{% endif %}>Пользователь</option>
<option value="admin" {% if target.role == 'admin' %}selected{% endif %}>Администратор</option>
<option value="system" {% if target.role == 'system' %}selected{% endif %}>Системный</option>
</select>
</label>
{% endif %}
<button type="submit">Сохранить</button>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}Пользователи — Администрирование — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex justify-between align-center mb-3">
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-people me-2"></i>Пользователи</h1>
<span class="text-muted small">Всего: {{ total }}</span>
</div>
<article class="card mb-3">
<div class="card-body">
<form method="get" action="/admin/users" class="d-flex gap-2 flex-wrap align-center">
<input type="text" name="search" value="{{ search }}" placeholder="Поиск по имени, email, телефону" style="flex:1; min-width:200px; margin:0;">
<select name="status" style="width:auto; margin:0;">
<option value="">Все статусы</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Ожидает</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Активен</option>
<option value="suspended" {% if status_filter == 'suspended' %}selected{% endif %}>Заблокирован</option>
</select>
<select name="role" style="width:auto; margin:0;">
<option value="">Все роли</option>
<option value="user" {% if role_filter == 'user' %}selected{% endif %}>Пользователь</option>
<option value="admin" {% if role_filter == 'admin' %}selected{% endif %}>Администратор</option>
<option value="system" {% if role_filter == 'system' %}selected{% endif %}>Системный</option>
</select>
<button type="submit" class="sm">Найти</button>
{% if search or status_filter or role_filter %}
<a href="/admin/users" role="button" class="outline secondary sm">Сбросить</a>
{% endif %}
</form>
</div>
</article>
<article class="card">
<div class="table-scroll">
<table class="align-middle">
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Email</th>
<th>Телефон</th>
<th>Роль</th>
<th>Статус</th>
<th>Эвотор</th>
<th>Регистрация</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="text-muted small">{{ u.id }}</td>
<td>{{ u.first_name }} {{ u.last_name }}</td>
<td>
{{ u.email }}
{% if not u.is_email_confirmed %}
<span class="badge badge-warning ms-1" title="Email не подтверждён"><i class="bi bi-exclamation-circle"></i></span>
{% endif %}
</td>
<td>{{ u.phone }}</td>
<td>
{% if u.role == 'system' %}<span class="badge badge-danger">Системный</span>
{% elif u.role == 'admin' %}<span class="badge badge-warning">Админ</span>
{% else %}<span class="badge badge-secondary">Польз.</span>
{% endif %}
</td>
<td>
{% if u.status == 'active' %}<span class="badge badge-success">Активен</span>
{% elif u.status == 'pending' %}<span class="badge badge-warning">Ожидает</span>
{% else %}<span class="badge badge-danger">Заблок.</span>
{% endif %}
</td>
<td>
{% if u.evotor_user_id %}
<i class="bi bi-check-circle text-success" title="{{ u.evotor_user_id }}"></i>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-muted small">{{ u.created_at | datefmt }}</td>
<td>
<a href="/admin/users/{{ u.id }}" role="button" class="outline sm">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="9" class="text-center text-muted py-4">Пользователи не найдены</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<footer>
<div class="d-flex gap-2 justify-center align-center">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" role="button" class="outline sm">«</a>
{% endif %}
<span class="text-muted small">Стр. {{ page }} из {{ total_pages }}</span>
{% if page < total_pages %}
<a href="?page={{ page + 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" role="button" class="outline sm">»</a>
{% endif %}
</div>
</footer>
{% endif %}
</article>
{% if user.role == 'system' %}
<div class="mt-3 text-end">
<a href="/admin/roles" role="button" class="outline secondary sm">
<i class="bi bi-shield-lock me-1"></i>Управление ролями
</a>
</div>
{% endif %}
{% endblock %}

106
web/templates/base.html Normal file
View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="ru" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ЭВОСИНК{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<nav class="container">
<ul>
<li><a href="/" class="brand-logo">ЭВОСИНК</a></li>
</ul>
<ul class="nav-links">
{% if user %}
<li><a href="/connections">Подключения</a></li>
<li><a href="/catalog">Каталог</a></li>
<li><a href="/sync">Синхронизация</a></li>
{% if user.role in ('admin', 'system') %}
<li><a href="/admin/users"><i class="bi bi-shield-lock"></i> Админ</a></li>
{% endif %}
<li><a href="/profile"><i class="bi bi-person-circle"></i> Личный кабинет</a></li>
<li><a href="/logout" class="secondary">Выход</a></li>
{% else %}
<li><a href="/login">Вход</a></li>
<li><a href="/register">Регистрация</a></li>
{% endif %}
</ul>
{% if user %}
<details class="mobile-menu">
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
<ul>
<li><a href="/connections">Подключения</a></li>
<li><a href="/catalog">Каталог</a></li>
<li><a href="/sync">Синхронизация</a></li>
{% if user.role in ('admin', 'system') %}
<li><a href="/admin/users">Админ</a></li>
{% endif %}
<li><a href="/profile">Личный кабинет</a></li>
<li><a href="/logout">Выход</a></li>
</ul>
</details>
{% else %}
<details class="mobile-menu">
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
<ul>
<li><a href="/login">Вход</a></li>
<li><a href="/register">Регистрация</a></li>
</ul>
</details>
{% endif %}
</nav>
</header>
<main class="container py-4">
{% if errors %}
<div role="alert" class="alert alert-danger">
{% for error in errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% if success %}
<div role="alert" class="alert alert-success">
<p>{{ success }}</p>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
{% if jivosite_widget_id %}
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var phoneInputs = document.querySelectorAll('input[name="phone"]');
if (phoneInputs.length) {
Inputmask('+7 (999) 999-99-99', {
placeholder: '_',
showMaskOnHover: false,
clearMaskOnLostFocus: false
}).mask(phoneInputs);
}
});
</script>
<script>
document.addEventListener('invalid', function(e) {
if (e.target.validity.valueMissing) {
e.target.setCustomValidity('Пожалуйста, заполните это поле');
} else if (e.target.validity.typeMismatch) {
e.target.setCustomValidity('Пожалуйста, введите корректное значение');
}
}, true);
document.addEventListener('input', function(e) {
if (e.target.required) e.target.setCustomValidity('');
}, true);
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Подтверждение email — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<i class="bi bi-envelope-check fs-1 text-primary mb-3 d-block"></i>
<h1 style="font-size:1.3rem;" class="mb-3">Подтвердите ваш email</h1>
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Email подтвержден — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<i class="bi bi-check-circle fs-1 text-success mb-3 d-block"></i>
<h1 style="font-size:1.3rem;" class="mb-3">Email подтвержден!</h1>
<p class="text-muted">Ваш email успешно подтвержден. Теперь вы можете войти в систему.</p>
<a href="/login" role="button" class="mt-2">Войти</a>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Забыли пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 style="font-size:1.3rem;" class="mb-2">Забыли пароль?</h1>
<p class="text-muted small mb-4">Введите email, указанный при регистрации.</p>
<form method="post" action="/forgot-password">
<label for="email">Email
<input type="email" id="email" name="email" required>
</label>
<button type="submit" class="w-100">Отправить ссылку для сброса</button>
</form>
<div class="text-center small mt-3">
<a href="/login">Вернуться ко входу</a>
</div>
</div>
</article>
</div>
</div>
{% endblock %}

44
web/templates/invite.html Normal file
View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Завершение регистрации — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<article class="card mt-4">
<header>
<h1><i class="bi bi-person-plus me-2"></i>Добро пожаловать в ЭВОСИНК!</h1>
</header>
<div class="card-body">
<p class="text-muted mb-4">Ваш аккаунт был создан через Эвотор. Заполните данные профиля и задайте пароль для входа.</p>
<form method="post" action="/invite?token={{ token }}">
<div class="row gap-2 mb-2">
<div class="col">
<label for="first_name">Имя <span class="text-danger">*</span>
<input type="text" id="first_name" name="first_name" value="{{ form.first_name if form else (invite_user.first_name or '') }}" required>
</label>
</div>
<div class="col">
<label for="last_name">Фамилия <span class="text-danger">*</span>
<input type="text" id="last_name" name="last_name" value="{{ form.last_name if form else (invite_user.last_name or '') }}" required>
</label>
</div>
</div>
<label for="email">Email <span class="text-danger">*</span>
<input type="email" id="email" name="email" value="{{ form.email if form else (invite_user.email or '') }}" required>
</label>
<label for="phone">Телефон <span class="text-danger">*</span>
<input type="tel" id="phone" name="phone" value="{{ form.phone if form else (invite_user.phone or '') }}" required>
</label>
<label for="password">Пароль <span class="text-danger">*</span>
<input type="password" id="password" name="password" required minlength="8">
</label>
<label for="password_confirm">Подтверждение пароля <span class="text-danger">*</span>
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<button type="submit" class="w-100">Завершить регистрацию</button>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

27
web/templates/login.html Normal file
View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Вход — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 class="mb-4" style="font-size:1.3rem;">Вход</h1>
<form method="post" action="/login">
<label for="email">Email
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
<label for="password">Пароль
<input type="password" id="password" name="password" required>
</label>
<button type="submit" class="w-100">Войти</button>
</form>
<div class="text-center small mt-3">
<a href="/forgot-password">Забыли пароль?</a><br>
<a href="/register">Зарегистрироваться</a>
</div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}{{ title }} — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<h1 style="font-size:1.3rem;" class="mb-3">{{ title }}</h1>
<p class="text-muted">{{ message }}</p>
{% if link %}
<a href="{{ link }}" role="button" class="mt-2">{{ link_text }}</a>
{% endif %}
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Изменить пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<header>
<h1><i class="bi bi-key me-2"></i>Изменить пароль</h1>
</header>
<div class="card-body">
<form method="post" action="/profile/change-password">
<label for="current_password">Текущий пароль
<input type="password" id="current_password" name="current_password" required>
</label>
<label for="password">Новый пароль
<input type="password" id="password" name="password" required>
</label>
<label for="password_confirm">Подтвердить пароль
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<div class="d-flex gap-2">
<button type="submit">Изменить пароль</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4" style="border-color: #dc2626;">
<header class="bg-danger-header">
<h1><i class="bi bi-trash me-2"></i>Удалить аккаунт</h1>
</header>
<div class="card-body">
<div role="alert" class="alert alert-warning mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Внимание!</strong> Это действие необратимо. Все ваши данные будут удалены.
</div>
<form method="post" action="/profile/delete">
<label for="password">Введите пароль для подтверждения
<input type="password" id="password" name="password" required>
</label>
<div class="d-flex gap-2">
<button type="submit" class="danger">
<i class="bi bi-trash me-1"></i>Удалить мой аккаунт
</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<article class="card mt-4">
<header>
<h1><i class="bi bi-pencil me-2"></i>Редактировать профиль</h1>
</header>
<div class="card-body">
<form method="post" action="/profile/edit">
<div class="row gap-2 mb-2">
<div class="col">
<label for="first_name">Имя
<input type="text" id="first_name" name="first_name"
value="{{ form.first_name if form else user.first_name }}" required>
</label>
</div>
<div class="col">
<label for="last_name">Фамилия
<input type="text" id="last_name" name="last_name"
value="{{ form.last_name if form else user.last_name }}" required>
</label>
</div>
</div>
<label>Email
<input type="email" value="{{ user.email }}" disabled>
</label>
<label for="phone">Телефон
<input type="tel" id="phone" name="phone"
value="{{ form.phone if form else user.phone }}" required>
</label>
<div class="d-flex gap-2">
<button type="submit">Сохранить</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Личный кабинет — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<article class="card mt-4">
<header>
<h1><i class="bi bi-person-circle me-2"></i>Личный кабинет</h1>
</header>
<ul class="list-group">
<li class="list-group-item">
<span class="text-muted small">Имя</span>
<span>{{ user.first_name }}</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Фамилия</span>
<span>{{ user.last_name }}</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Email</span>
<span>
{{ user.email }}
{% if user.is_email_confirmed %}
<span class="badge badge-success ms-1"><i class="bi bi-check-circle"></i> подтверждён</span>
{% else %}
<span class="badge badge-warning ms-1"><i class="bi bi-exclamation-circle"></i> не подтверждён</span>
{% endif %}
</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Телефон</span>
<span>{{ user.phone }}</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Роль</span>
<span>
{% if user.role == 'system' %}<span class="badge badge-danger">Системный</span>
{% elif user.role == 'admin' %}<span class="badge badge-warning">Администратор</span>
{% else %}<span class="badge badge-secondary">Пользователь</span>
{% endif %}
</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Статус</span>
<span>
{% if user.status == 'active' %}<span class="badge badge-success">Активен</span>
{% elif user.status == 'pending' %}<span class="badge badge-warning">Ожидает подтверждения</span>
{% else %}<span class="badge badge-danger">Заблокирован</span>
{% endif %}
</span>
</li>
{% if user.evotor_user_id %}
<li class="list-group-item">
<span class="text-muted small">Эвотор ID</span>
<span class="font-monospace small">{{ user.evotor_user_id }}</span>
</li>
{% endif %}
<li class="list-group-item">
<span class="text-muted small">Регистрация</span>
<span>{{ user.created_at | datefmt }}</span>
</li>
</ul>
<div class="card-body d-grid gap-2">
<a href="/profile/edit" role="button">
<i class="bi bi-pencil me-1"></i>Редактировать профиль
</a>
<a href="/profile/change-password" role="button" class="secondary">
<i class="bi bi-key me-1"></i>Изменить пароль
</a>
{% if not user.is_email_confirmed %}
<a href="/resend-confirm" role="button" class="outline secondary">
<i class="bi bi-envelope me-1"></i>Отправить письмо с подтверждением
</a>
{% endif %}
<a href="/logout" role="button" class="outline secondary">
<i class="bi bi-box-arrow-right me-1"></i>Выход
</a>
<a href="/profile/delete" role="button" class="outline danger sm mt-2">
<i class="bi bi-trash me-1"></i>Удалить аккаунт
</a>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Регистрация — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<article class="card mt-4">
<div class="card-body">
<h1 class="mb-4" style="font-size:1.3rem;">Регистрация</h1>
<form method="post" action="/register">
<div class="row gap-2 mb-2">
<div class="col">
<label for="first_name">Имя
<input type="text" id="first_name" name="first_name" value="{{ form.first_name if form else '' }}">
</label>
</div>
<div class="col">
<label for="last_name">Фамилия
<input type="text" id="last_name" name="last_name" value="{{ form.last_name if form else '' }}">
</label>
</div>
</div>
<label for="email">Email <span class="text-danger">*</span>
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
<label for="phone">Телефон <span class="text-danger">*</span>
<input type="tel" id="phone" name="phone" value="{{ form.phone if form else '' }}" required>
</label>
<label for="password">Пароль <span class="text-danger">*</span>
<input type="password" id="password" name="password" required>
</label>
<label for="password_confirm">Подтверждение пароля <span class="text-danger">*</span>
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<button type="submit" class="w-100">Зарегистрироваться</button>
</form>
<div class="text-center small mt-3">
<a href="/login">Уже есть аккаунт? Войти</a>
</div>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Новый пароль — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 style="font-size:1.3rem;" class="mb-4">Новый пароль</h1>
<form method="post" action="/reset-password?token={{ token }}">
<label for="password">Новый пароль
<input type="password" id="password" name="password" required>
</label>
<label for="password_confirm">Подтверждение пароля
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<button type="submit" class="w-100">Сменить пароль</button>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

21
web/templates_env.py Normal file
View File

@@ -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