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:
0
web/auth/__init__.py
Normal file
0
web/auth/__init__.py
Normal file
11
web/auth/password.py
Normal file
11
web/auth/password.py
Normal 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
35
web/auth/rbac.py
Normal 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
25
web/auth/session.py
Normal 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)
|
||||
@@ -16,6 +16,11 @@ class Settings(BaseSettings):
|
||||
VK_API_VERSION: str = "5.199"
|
||||
|
||||
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
|
||||
INVITE_EXPIRE_HOURS: int = 48
|
||||
EMAIL_PROVIDER: str = "console"
|
||||
SMS_PROVIDER: str = "console"
|
||||
SYSTEM_USER_EMAIL: str = ""
|
||||
SYSTEM_USER_PASSWORD: str = ""
|
||||
|
||||
FLOWER_USER: str = "admin"
|
||||
FLOWER_PASSWORD: str = "changeme"
|
||||
|
||||
63
web/main.py
63
web/main.py
@@ -1,6 +1,9 @@
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
try:
|
||||
from pythonjsonlogger import jsonlogger
|
||||
@@ -11,9 +14,65 @@ except ImportError:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.root.setLevel(logging.INFO)
|
||||
|
||||
app = FastAPI(title="EvoSync")
|
||||
from web.config import settings # noqa: E402 — after logging setup
|
||||
from web.templates_env import templates # noqa: E402
|
||||
|
||||
app = FastAPI(title="ЭвоСинк")
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.SECRET_KEY,
|
||||
max_age=86400 * 30,
|
||||
https_only=False,
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
||||
|
||||
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||
from web.routes.auth import router as auth_router # noqa: E402
|
||||
from web.routes.reset import router as reset_router # noqa: E402
|
||||
from web.routes.invite import router as invite_router # noqa: E402
|
||||
from web.routes.profile import router as profile_router # noqa: E402
|
||||
from web.routes.evotor_webhooks import router as evotor_webhooks_router # noqa: E402
|
||||
from web.routes.admin import router as admin_router # noqa: E402
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(reset_router)
|
||||
app.include_router(invite_router)
|
||||
app.include_router(profile_router)
|
||||
app.include_router(evotor_webhooks_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
# ── Health ────────────────────────────────────────────────────────────────────
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Root redirect ─────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
async def root(request: Request):
|
||||
from fastapi.responses import RedirectResponse
|
||||
user_id = request.session.get("user_id")
|
||||
if user_id:
|
||||
return RedirectResponse("/profile", 303)
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
|
||||
# ── 403 handler ───────────────────────────────────────────────────────────────
|
||||
from fastapi import HTTPException # noqa: E402
|
||||
from fastapi.exception_handlers import http_exception_handler # noqa: E402
|
||||
|
||||
|
||||
@app.exception_handler(403)
|
||||
async def forbidden_handler(request: Request, exc: HTTPException) -> HTMLResponse:
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request,
|
||||
"user": None,
|
||||
"title": "Нет доступа",
|
||||
"message": "У вас недостаточно прав для просмотра этой страницы.",
|
||||
"link": "/profile",
|
||||
"link_text": "В личный кабинет",
|
||||
"jivosite_widget_id": settings.JIVOSITE_WIDGET_ID,
|
||||
}, status_code=403)
|
||||
|
||||
238
web/migrations/versions/0002_users_and_connections.py
Normal file
238
web/migrations/versions/0002_users_and_connections.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""users and connections schema
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-04-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: Union[str, None] = "0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── users ────────────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
password_hash VARCHAR(255) NULL,
|
||||
is_email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
email_confirm_token VARCHAR(255) NULL,
|
||||
password_reset_token VARCHAR(255) NULL,
|
||||
password_reset_expires DATETIME NULL,
|
||||
role ENUM('system','admin','user') NOT NULL DEFAULT 'user',
|
||||
status ENUM('pending','active','suspended') NOT NULL DEFAULT 'pending',
|
||||
evotor_user_id VARCHAR(255) NULL,
|
||||
evotor_meta JSON NULL,
|
||||
invite_token VARCHAR(255) NULL,
|
||||
invite_expires DATETIME NULL,
|
||||
phone_otp VARCHAR(10) NULL,
|
||||
phone_otp_expires DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_users_email UNIQUE (email),
|
||||
CONSTRAINT ix_users_phone UNIQUE (phone),
|
||||
CONSTRAINT ix_users_evotor_user_id UNIQUE (evotor_user_id)
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# Add new columns if migrating from the Node.js schema (which lacked them)
|
||||
for col_sql in [
|
||||
"ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(255) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role ENUM('system','admin','user') NOT NULL DEFAULT 'user'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('pending','active','suspended') NOT NULL DEFAULT 'pending'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS evotor_user_id VARCHAR(255) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS evotor_meta JSON NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_token VARCHAR(255) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_expires DATETIME NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_otp VARCHAR(10) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_otp_expires DATETIME NULL",
|
||||
]:
|
||||
try:
|
||||
conn.execute(sa.text(col_sql))
|
||||
except Exception:
|
||||
pass # column/constraint already exists
|
||||
|
||||
# Add unique index on evotor_user_id if missing
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE users ADD CONSTRAINT ix_users_evotor_user_id UNIQUE (evotor_user_id)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add role/status indexes if missing
|
||||
for idx_sql in [
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_role ON users (role)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_status ON users (status)",
|
||||
]:
|
||||
try:
|
||||
conn.execute(sa.text(idx_sql))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── evotor_connections ───────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS evotor_connections (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NULL,
|
||||
evotor_user_id VARCHAR(255) NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
api_token VARCHAR(255) NULL,
|
||||
store_id VARCHAR(255) NULL,
|
||||
store_name VARCHAR(255) NULL,
|
||||
refresh_token TEXT NULL,
|
||||
token_expires_at DATETIME NULL,
|
||||
is_online BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_checked_at DATETIME NULL,
|
||||
connected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_evotor_connections_user_id UNIQUE (user_id),
|
||||
CONSTRAINT ix_evotor_connections_evotor_user_id UNIQUE (evotor_user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE evotor_connections ADD COLUMN IF NOT EXISTS api_token VARCHAR(255) NULL"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── vk_connections ───────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS vk_connections (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
vk_user_id VARCHAR(50) NULL,
|
||||
first_name VARCHAR(255) NULL,
|
||||
last_name VARCHAR(255) NULL,
|
||||
is_online BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_checked_at DATETIME NULL,
|
||||
connected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_vk_connections_user_id UNIQUE (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# ── sync_configs ─────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS sync_configs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
confirmed_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_sync_configs_user_id UNIQUE (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# ── sync_filters ─────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS sync_filters (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
sync_config_id INT NOT NULL,
|
||||
entity_type VARCHAR(20) NOT NULL,
|
||||
entity_id VARCHAR(255) NOT NULL,
|
||||
entity_name VARCHAR(255) NULL,
|
||||
filter_mode VARCHAR(10) NOT NULL,
|
||||
parent_entity_id VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_sync_filters_config_type_entity UNIQUE (sync_config_id, entity_type, entity_id),
|
||||
FOREIGN KEY (sync_config_id) REFERENCES sync_configs(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# ── cached_stores ────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS cached_stores (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
evotor_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address VARCHAR(500) NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
CONSTRAINT uq_cached_stores_user_evotor UNIQUE (user_id, evotor_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
try:
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_cached_stores_user_id ON cached_stores (user_id)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── cached_groups ────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS cached_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
evotor_id VARCHAR(255) NOT NULL,
|
||||
store_evotor_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
CONSTRAINT uq_cached_groups_user_evotor UNIQUE (user_id, evotor_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_cached_groups_user_store ON cached_groups (user_id, store_evotor_id)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── cached_products ──────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS cached_products (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
evotor_id VARCHAR(255) NOT NULL,
|
||||
store_evotor_id VARCHAR(255) NOT NULL,
|
||||
group_evotor_id VARCHAR(255) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
price DECIMAL(12,2) NULL,
|
||||
quantity DECIMAL(12,3) NULL,
|
||||
measure_name VARCHAR(20) NULL,
|
||||
article_number VARCHAR(100) NULL,
|
||||
allow_to_sell BOOLEAN NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
synced_at DATETIME NULL,
|
||||
CONSTRAINT uq_cached_products_user_evotor UNIQUE (user_id, evotor_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_cached_products_user_store_group "
|
||||
"ON cached_products (user_id, store_evotor_id, group_evotor_id)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in [
|
||||
"cached_products", "cached_groups", "cached_stores",
|
||||
"sync_filters", "sync_configs",
|
||||
"vk_connections", "evotor_connections",
|
||||
"users",
|
||||
]:
|
||||
conn.execute(sa.text(f"DROP TABLE IF EXISTS {table}"))
|
||||
105
web/migrations/versions/0003_rbac_tables.py
Normal file
105
web/migrations/versions/0003_rbac_tables.py
Normal 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}"))
|
||||
@@ -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
136
web/models/connections.py
Normal 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
34
web/models/rbac.py
Normal 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
50
web/models/user.py
Normal 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"),
|
||||
)
|
||||
0
web/notifications/__init__.py
Normal file
0
web/notifications/__init__.py
Normal file
11
web/notifications/base.py
Normal file
11
web/notifications/base.py
Normal 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: ...
|
||||
28
web/notifications/console.py
Normal file
28
web/notifications/console.py
Normal 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)
|
||||
17
web/notifications/registry.py
Normal file
17
web/notifications/registry.py
Normal 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}")
|
||||
12
web/notifications/tasks.py
Normal file
12
web/notifications/tasks.py
Normal 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
0
web/routes/__init__.py
Normal file
271
web/routes/admin.py
Normal file
271
web/routes/admin.py
Normal 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
170
web/routes/auth.py
Normal 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)
|
||||
269
web/routes/evotor_webhooks.py
Normal file
269
web/routes/evotor_webhooks.py
Normal 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
99
web/routes/invite.py
Normal 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
143
web/routes/profile.py
Normal 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
101
web/routes/reset.py
Normal 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
431
web/static/style.css
Normal 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;
|
||||
}
|
||||
@@ -18,5 +18,6 @@ celery_app.conf.update(
|
||||
"web.tasks.sync.*": {"queue": "sync"},
|
||||
"web.tasks.health.*": {"queue": "health"},
|
||||
"web.tasks.catalog.*": {"queue": "default"},
|
||||
"web.notifications.tasks.*": {"queue": "notifications"},
|
||||
},
|
||||
)
|
||||
|
||||
40
web/templates/admin/roles.html
Normal file
40
web/templates/admin/roles.html
Normal 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 %}
|
||||
147
web/templates/admin/user_detail.html
Normal file
147
web/templates/admin/user_detail.html
Normal 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 %}
|
||||
118
web/templates/admin/users.html
Normal file
118
web/templates/admin/users.html
Normal 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
106
web/templates/base.html
Normal 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>
|
||||
16
web/templates/confirm_email.html
Normal file
16
web/templates/confirm_email.html
Normal 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 %}
|
||||
17
web/templates/email_confirmed.html
Normal file
17
web/templates/email_confirmed.html
Normal 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 %}
|
||||
24
web/templates/forgot_password.html
Normal file
24
web/templates/forgot_password.html
Normal 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
44
web/templates/invite.html
Normal 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
27
web/templates/login.html
Normal 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 %}
|
||||
18
web/templates/message.html
Normal file
18
web/templates/message.html
Normal 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 %}
|
||||
31
web/templates/profile_change_password.html
Normal file
31
web/templates/profile_change_password.html
Normal 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 %}
|
||||
31
web/templates/profile_delete.html
Normal file
31
web/templates/profile_delete.html
Normal 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 %}
|
||||
43
web/templates/profile_edit.html
Normal file
43
web/templates/profile_edit.html
Normal 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 %}
|
||||
86
web/templates/profile_view.html
Normal file
86
web/templates/profile_view.html
Normal 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 %}
|
||||
44
web/templates/register.html
Normal file
44
web/templates/register.html
Normal 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 %}
|
||||
23
web/templates/reset_password.html
Normal file
23
web/templates/reset_password.html
Normal 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
21
web/templates_env.py
Normal 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
|
||||
Reference in New Issue
Block a user