Migrate web app from Python/FastAPI to Node.js/TypeScript
Replace the entire Python/FastAPI backend with a Node.js/TypeScript stack: - Framework: Hono + @hono/node-server - Templates: Nunjucks (.njk) replacing Jinja2 (.html) - ORM: Drizzle ORM with mysql2 (same MariaDB schema, no migrations needed) - Sessions: hono-sessions with CookieStore - CSS: Pico CSS v2 replacing Bootstrap 5 (Bootstrap Icons CDN kept) - Dev: tsx watch; Prod: tsc + node dist/index.js Original Python app preserved in web-python/ as backup. Updated Dockerfile.web and docker-compose.yml for Node.js deployment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
web/.gitignore
vendored
Normal file
2
web/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
23
web/auth.py
23
web/auth.py
@@ -1,23 +0,0 @@
|
||||
from fastapi import Request, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from web.database import get_db
|
||||
from web.models import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
@@ -1,34 +0,0 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync"
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
BASE_URL: str = "http://localhost:8080"
|
||||
PASSWORD_RESET_EXPIRE_MINUTES: int = 60
|
||||
|
||||
EVOTOR_APP_ID: str = ""
|
||||
EVOTOR_WEBHOOK_SECRET: str = ""
|
||||
|
||||
JIVOSITE_WIDGET_ID: str = ""
|
||||
|
||||
HEALTH_CHECK_INTERVAL_SECONDS: int = 600
|
||||
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
|
||||
SYNC_INTERVAL_SECONDS: int = 3600
|
||||
|
||||
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
|
||||
|
||||
VK_CLIENT_ID: str = ""
|
||||
VK_CLIENT_SECRET: str = ""
|
||||
VK_API_VERSION: str = "5.131"
|
||||
|
||||
# Docker compose vars (ignored in app, kept for env compatibility)
|
||||
DB_ROOT_PASSWORD: str = ""
|
||||
DB_NAME: str = ""
|
||||
DB_USER: str = ""
|
||||
DB_PASSWORD: str = ""
|
||||
|
||||
model_config = {"env_file": ".env", "case_sensitive": False}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -1,19 +0,0 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
|
||||
from web.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
10
web/drizzle.config.ts
Normal file
10
web/drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "mysql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "mysql://evosync:evosync@localhost:3306/evosync",
|
||||
},
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
EVOTOR_API_BASE = "https://api.evotor.ru"
|
||||
|
||||
|
||||
async def fetch_stores(access_token: str) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{EVOTOR_API_BASE}/stores",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("items", data) if isinstance(data, dict) else data
|
||||
return [
|
||||
{
|
||||
"id": s.get("uuid") or s.get("id"),
|
||||
"name": s.get("name"),
|
||||
"address": s.get("address"),
|
||||
}
|
||||
for s in items
|
||||
]
|
||||
|
||||
|
||||
async def fetch_groups(access_token: str, store_id: str) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{EVOTOR_API_BASE}/stores/{store_id}/product-groups",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 402:
|
||||
return []
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("items", data) if isinstance(data, dict) else data
|
||||
return [{"id": g.get("uuid") or g.get("id"), "name": g.get("name")} for g in items]
|
||||
|
||||
|
||||
async def fetch_products(access_token: str, store_id: str) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{EVOTOR_API_BASE}/stores/{store_id}/products",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 402:
|
||||
return []
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("items", data) if isinstance(data, dict) else data
|
||||
return [
|
||||
{
|
||||
"id": p.get("uuid") or p.get("id"),
|
||||
"name": p.get("name"),
|
||||
"parent_id": p.get("parentUuid") or p.get("parent_id"),
|
||||
"price": p.get("price"),
|
||||
"quantity": p.get("quantity"),
|
||||
"measure_name": p.get("measureName") or p.get("measure_name"),
|
||||
"article_number": p.get("code") or p.get("article_number"),
|
||||
"allow_to_sell": p.get("allowToSell") if p.get("allowToSell") is not None else p.get("allow_to_sell"),
|
||||
}
|
||||
for p in items
|
||||
]
|
||||
|
||||
|
||||
async def refresh_catalog_cache(user_id: int, access_token: str, db: Session) -> None:
|
||||
from web.models import CachedStore, CachedGroup, CachedProduct
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Delete old cache for user
|
||||
db.query(CachedProduct).filter(CachedProduct.user_id == user_id).delete()
|
||||
db.query(CachedGroup).filter(CachedGroup.user_id == user_id).delete()
|
||||
db.query(CachedStore).filter(CachedStore.user_id == user_id).delete()
|
||||
db.commit()
|
||||
|
||||
stores = await fetch_stores(access_token)
|
||||
for store in stores:
|
||||
db.add(CachedStore(
|
||||
user_id=user_id,
|
||||
evotor_id=store["id"],
|
||||
name=store["name"] or "",
|
||||
address=store.get("address"),
|
||||
fetched_at=now,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
for store in stores:
|
||||
groups = await fetch_groups(access_token, store["id"])
|
||||
for group in groups:
|
||||
db.add(CachedGroup(
|
||||
user_id=user_id,
|
||||
evotor_id=group["id"],
|
||||
store_evotor_id=store["id"],
|
||||
name=group["name"] or "",
|
||||
fetched_at=now,
|
||||
))
|
||||
|
||||
products = await fetch_products(access_token, store["id"])
|
||||
for product in products:
|
||||
db.add(CachedProduct(
|
||||
user_id=user_id,
|
||||
evotor_id=product["id"],
|
||||
store_evotor_id=store["id"],
|
||||
group_evotor_id=product.get("parent_id"),
|
||||
name=product["name"] or "",
|
||||
price=product.get("price"),
|
||||
quantity=product.get("quantity"),
|
||||
measure_name=product.get("measure_name"),
|
||||
article_number=product.get("article_number"),
|
||||
allow_to_sell=product.get("allow_to_sell"),
|
||||
fetched_at=now,
|
||||
))
|
||||
db.commit()
|
||||
@@ -1,152 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from web.database import SessionLocal
|
||||
from web.models import EvotorConnection, VkConnection, CachedStore
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
||||
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
|
||||
VK_GROUPS_GET_URL = "https://api.vk.com/method/groups.getById"
|
||||
VK_API_VERSION = "5.131"
|
||||
|
||||
# Refresh Evotor token if it expires within this window
|
||||
REFRESH_BEFORE_EXPIRY = timedelta(hours=2)
|
||||
|
||||
|
||||
async def _refresh_evotor_token(conn: EvotorConnection) -> str | None:
|
||||
"""Attempt to refresh the Evotor access token. Returns new access token or None."""
|
||||
from web.config import settings
|
||||
if not conn.refresh_token:
|
||||
return None
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
EVOTOR_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": conn.refresh_token,
|
||||
},
|
||||
auth=(settings.EVOTOR_CLIENT_ID, settings.EVOTOR_CLIENT_SECRET),
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
return data if data.get("access_token") else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def check_evotor_connection(access_token: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
EVOTOR_STORES_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=15,
|
||||
)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def check_vk_connection(access_token: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
VK_GROUPS_GET_URL,
|
||||
params={"access_token": access_token, "v": VK_API_VERSION},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
data = resp.json()
|
||||
return "error" not in data
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def run_health_checks() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
|
||||
evotor_connections = db.query(EvotorConnection).all()
|
||||
for conn in evotor_connections:
|
||||
# Proactively refresh if token expires soon
|
||||
needs_refresh = (
|
||||
conn.refresh_token and
|
||||
conn.token_expires_at and
|
||||
conn.token_expires_at - now < REFRESH_BEFORE_EXPIRY
|
||||
)
|
||||
if needs_refresh:
|
||||
token_data = await _refresh_evotor_token(conn)
|
||||
if token_data:
|
||||
conn.access_token = token_data["access_token"]
|
||||
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
|
||||
expires_in = token_data.get("expires_in")
|
||||
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
|
||||
logger.info("Refreshed Evotor token for user_id=%d", conn.user_id)
|
||||
|
||||
is_online = await check_evotor_connection(conn.access_token)
|
||||
|
||||
# If offline and not yet tried refresh, attempt it now
|
||||
if not is_online and conn.refresh_token and not needs_refresh:
|
||||
token_data = await _refresh_evotor_token(conn)
|
||||
if token_data:
|
||||
conn.access_token = token_data["access_token"]
|
||||
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
|
||||
expires_in = token_data.get("expires_in")
|
||||
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
|
||||
is_online = await check_evotor_connection(conn.access_token)
|
||||
if is_online:
|
||||
logger.info("Evotor token refreshed after failed check for user_id=%d", conn.user_id)
|
||||
|
||||
conn.is_online = is_online
|
||||
conn.last_checked_at = now
|
||||
|
||||
vk_connections = db.query(VkConnection).all()
|
||||
for conn in vk_connections:
|
||||
conn.is_online = await check_vk_connection(conn.access_token)
|
||||
conn.last_checked_at = now
|
||||
|
||||
db.commit()
|
||||
|
||||
# Refresh catalog cache for online Evotor connections
|
||||
from web.config import settings
|
||||
refreshed_catalog = 0
|
||||
for conn in evotor_connections:
|
||||
if not conn.is_online:
|
||||
continue
|
||||
cached = db.query(CachedStore).filter(CachedStore.user_id == conn.user_id).first()
|
||||
cache_age = (now - cached.fetched_at).total_seconds() if cached else None
|
||||
if cached is None or cache_age >= settings.CATALOG_REFRESH_INTERVAL_SECONDS:
|
||||
try:
|
||||
from web.evotor_api import refresh_catalog_cache
|
||||
await refresh_catalog_cache(conn.user_id, conn.access_token, db)
|
||||
refreshed_catalog += 1
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh catalog cache for user_id=%d", conn.user_id)
|
||||
|
||||
logger.info(
|
||||
"Health checks completed: %d Evotor, %d VK, %d catalogs refreshed",
|
||||
len(evotor_connections),
|
||||
len(vk_connections),
|
||||
refreshed_catalog,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error during health checks")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def health_check_loop(interval: int) -> None:
|
||||
while True:
|
||||
await run_health_checks()
|
||||
await asyncio.sleep(interval)
|
||||
53
web/main.py
53
web/main.py
@@ -1,53 +0,0 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Depends, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from web.auth import get_current_user
|
||||
from web.config import settings
|
||||
from web.health_checker import health_check_loop
|
||||
from web.sync_engine import sync_loop
|
||||
from web.models import User
|
||||
from web.routes import auth, profile, reset, evotor, vk, sync, catalog
|
||||
from web.routes import connections
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
tasks = [
|
||||
asyncio.create_task(health_check_loop(settings.HEALTH_CHECK_INTERVAL_SECONDS)),
|
||||
asyncio.create_task(sync_loop(settings.SYNC_INTERVAL_SECONDS)),
|
||||
]
|
||||
yield
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="ЭВОСИНК — Личный кабинет", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
|
||||
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(reset.router)
|
||||
app.include_router(evotor.router)
|
||||
app.include_router(connections.router)
|
||||
app.include_router(vk.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(catalog.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def home(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if user:
|
||||
return RedirectResponse("/profile", 302)
|
||||
return RedirectResponse("/login", 302)
|
||||
@@ -1 +0,0 @@
|
||||
Generic single-database configuration.
|
||||
@@ -1,54 +0,0 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
from web.config import settings
|
||||
from web.database import Base
|
||||
from web.models import User, EvotorConnection # noqa: F401 — register models with Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -1,26 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -1,60 +0,0 @@
|
||||
"""initial
|
||||
|
||||
Revision ID: 2c15000e752b
|
||||
Revises:
|
||||
Create Date: 2026-03-06 09:07:16.180639
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '2c15000e752b'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("first_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("last_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("phone", sa.String(length=20), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("is_email_confirmed", sa.Boolean(), nullable=False),
|
||||
sa.Column("email_confirm_token", sa.String(length=255), nullable=True),
|
||||
sa.Column("password_reset_token", sa.String(length=255), nullable=True),
|
||||
sa.Column("password_reset_expires", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||
op.create_index(op.f("ix_users_phone"), "users", ["phone"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"evotor_connections",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("access_token", sa.Text(), nullable=False),
|
||||
sa.Column("store_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("store_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("connected_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("evotor_connections")
|
||||
op.drop_index(op.f("ix_users_phone"), table_name="users")
|
||||
op.drop_index(op.f("ix_users_email"), table_name="users")
|
||||
op.drop_table("users")
|
||||
@@ -1,26 +0,0 @@
|
||||
"""add is_online and last_checked_at to evotor_connections
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 2c15000e752b
|
||||
Create Date: 2026-03-06 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'a1b2c3d4e5f6'
|
||||
down_revision = '2c15000e752b'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('evotor_connections',
|
||||
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'))
|
||||
op.add_column('evotor_connections',
|
||||
sa.Column('last_checked_at', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('evotor_connections', 'last_checked_at')
|
||||
op.drop_column('evotor_connections', 'is_online')
|
||||
@@ -1,26 +0,0 @@
|
||||
"""add synced_at to cached_products
|
||||
|
||||
Revision ID: a7b8c9d0e1f2
|
||||
Revises: f6a7b8c9d0e1
|
||||
Branch Labels: None
|
||||
Depends On: None
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "a7b8c9d0e1f2"
|
||||
down_revision = "f6a7b8c9d0e1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"cached_products",
|
||||
sa.Column("synced_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("cached_products", "synced_at")
|
||||
@@ -1,37 +0,0 @@
|
||||
"""add vk_connections table
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-03-06 00:01:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'b2c3d4e5f6a7'
|
||||
down_revision = 'a1b2c3d4e5f6'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'vk_connections',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('access_token', sa.Text(), nullable=False),
|
||||
sa.Column('vk_user_id', sa.String(50), nullable=True),
|
||||
sa.Column('first_name', sa.String(255), nullable=True),
|
||||
sa.Column('last_name', sa.String(255), nullable=True),
|
||||
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('last_checked_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('connected_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('vk_connections')
|
||||
@@ -1,48 +0,0 @@
|
||||
"""add sync_configs and sync_filters tables
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-03-06 00:02:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'c3d4e5f6a7b8'
|
||||
down_revision = 'b2c3d4e5f6a7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'sync_configs',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id'),
|
||||
)
|
||||
op.create_table(
|
||||
'sync_filters',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('sync_config_id', sa.Integer(), nullable=False),
|
||||
sa.Column('entity_type', sa.String(20), nullable=False),
|
||||
sa.Column('entity_id', sa.String(255), nullable=False),
|
||||
sa.Column('entity_name', sa.String(255), nullable=True),
|
||||
sa.Column('filter_mode', sa.String(10), nullable=False),
|
||||
sa.Column('parent_entity_id', sa.String(255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(['sync_config_id'], ['sync_configs.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('sync_config_id', 'entity_type', 'entity_id', name='uq_sync_filter'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('sync_filters')
|
||||
op.drop_table('sync_configs')
|
||||
@@ -1,70 +0,0 @@
|
||||
"""add catalog cache tables
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-03-06 00:03:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'd4e5f6a7b8c9'
|
||||
down_revision = 'c3d4e5f6a7b8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'cached_stores',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('evotor_id', sa.String(255), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('address', sa.String(500), nullable=True),
|
||||
sa.Column('fetched_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_stores'),
|
||||
)
|
||||
op.create_index('ix_cached_stores_user_id', 'cached_stores', ['user_id'])
|
||||
|
||||
op.create_table(
|
||||
'cached_groups',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('evotor_id', sa.String(255), nullable=False),
|
||||
sa.Column('store_evotor_id', sa.String(255), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('fetched_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_groups'),
|
||||
)
|
||||
op.create_index('ix_cached_groups_user_store', 'cached_groups', ['user_id', 'store_evotor_id'])
|
||||
|
||||
op.create_table(
|
||||
'cached_products',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('evotor_id', sa.String(255), nullable=False),
|
||||
sa.Column('store_evotor_id', sa.String(255), nullable=False),
|
||||
sa.Column('group_evotor_id', sa.String(255), nullable=True),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('price', sa.Numeric(12, 2), nullable=True),
|
||||
sa.Column('quantity', sa.Numeric(12, 3), nullable=True),
|
||||
sa.Column('measure_name', sa.String(20), nullable=True),
|
||||
sa.Column('article_number', sa.String(100), nullable=True),
|
||||
sa.Column('allow_to_sell', sa.Boolean(), nullable=True),
|
||||
sa.Column('fetched_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_products'),
|
||||
)
|
||||
op.create_index('ix_cached_products_user_store_group', 'cached_products', ['user_id', 'store_evotor_id', 'group_evotor_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('cached_products')
|
||||
op.drop_table('cached_groups')
|
||||
op.drop_table('cached_stores')
|
||||
@@ -1,24 +0,0 @@
|
||||
"""add refresh_token and token_expires_at to evotor_connections
|
||||
|
||||
Revision ID: e5f6a7b8c9d0
|
||||
Revises: d4e5f6a7b8c9
|
||||
Create Date: 2026-03-06 00:04:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'e5f6a7b8c9d0'
|
||||
down_revision = 'd4e5f6a7b8c9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('evotor_connections', sa.Column('refresh_token', sa.Text(), nullable=True))
|
||||
op.add_column('evotor_connections', sa.Column('token_expires_at', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('evotor_connections', 'token_expires_at')
|
||||
op.drop_column('evotor_connections', 'refresh_token')
|
||||
@@ -1,53 +0,0 @@
|
||||
"""evotor webhook token flow: add evotor_user_id, make user_id nullable
|
||||
|
||||
Revision ID: f6a7b8c9d0e1
|
||||
Revises: e5f6a7b8c9d0
|
||||
Branch Labels: None
|
||||
Depends On: None
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'f6a7b8c9d0e1'
|
||||
down_revision = 'e5f6a7b8c9d0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Check existing columns
|
||||
columns = [row[0] for row in conn.execute(sa.text(
|
||||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'evotor_connections'"
|
||||
))]
|
||||
|
||||
if 'evotor_user_id' not in columns:
|
||||
op.add_column('evotor_connections',
|
||||
sa.Column('evotor_user_id', sa.String(255), nullable=True))
|
||||
|
||||
# Check existing indexes
|
||||
indexes = [row[2] for row in conn.execute(sa.text(
|
||||
"SHOW INDEX FROM evotor_connections"
|
||||
))]
|
||||
|
||||
if 'uq_evotor_connections_evotor_user_id' not in indexes:
|
||||
op.create_unique_constraint('uq_evotor_connections_evotor_user_id',
|
||||
'evotor_connections', ['evotor_user_id'])
|
||||
|
||||
if 'ix_evotor_connections_evotor_user_id' not in indexes:
|
||||
op.create_index('ix_evotor_connections_evotor_user_id',
|
||||
'evotor_connections', ['evotor_user_id'])
|
||||
|
||||
op.alter_column('evotor_connections', 'user_id',
|
||||
existing_type=sa.Integer(), nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column('evotor_connections', 'user_id',
|
||||
existing_type=sa.Integer(), nullable=False)
|
||||
op.drop_index('ix_evotor_connections_evotor_user_id', 'evotor_connections')
|
||||
op.drop_constraint('uq_evotor_connections_evotor_user_id', 'evotor_connections')
|
||||
op.drop_column('evotor_connections', 'evotor_user_id')
|
||||
159
web/models.py
159
web/models.py
@@ -1,159 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint, Numeric, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from web.database import Base
|
||||
|
||||
|
||||
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), unique=True, nullable=False, index=True)
|
||||
phone = Column(String(20), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
is_email_confirmed = Column(Boolean, default=False, nullable=False)
|
||||
email_confirm_token = Column(String(255), nullable=True)
|
||||
password_reset_token = Column(String(255), nullable=True)
|
||||
password_reset_expires = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
evotor_connection = relationship("EvotorConnection", back_populates="user", uselist=False)
|
||||
vk_connection = relationship("VkConnection", back_populates="user", uselist=False)
|
||||
sync_config = relationship("SyncConfig", back_populates="user", uselist=False)
|
||||
cached_stores = relationship("CachedStore", back_populates="user", cascade="all, delete-orphan")
|
||||
cached_groups = relationship("CachedGroup", back_populates="user", cascade="all, delete-orphan")
|
||||
cached_products = relationship("CachedProduct", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class EvotorConnection(Base):
|
||||
__tablename__ = "evotor_connections"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True)
|
||||
evotor_user_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
access_token = Column(Text, nullable=False)
|
||||
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, default=False, server_default="0", nullable=False)
|
||||
last_checked_at = Column(DateTime, nullable=True)
|
||||
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="evotor_connection")
|
||||
|
||||
|
||||
class VkConnection(Base):
|
||||
__tablename__ = "vk_connections"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, 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, default=False, server_default="0", nullable=False)
|
||||
last_checked_at = Column(DateTime, nullable=True)
|
||||
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="vk_connection")
|
||||
|
||||
|
||||
class SyncConfig(Base):
|
||||
__tablename__ = "sync_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
|
||||
is_enabled = Column(Boolean, default=False, nullable=False)
|
||||
confirmed_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="sync_config")
|
||||
filters = relationship("SyncFilter", back_populates="sync_config", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
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) # "store", "group", "product"
|
||||
entity_id = Column(String(255), nullable=False)
|
||||
entity_name = Column(String(255), nullable=True)
|
||||
filter_mode = Column(String(10), nullable=False) # "include", "exclude"
|
||||
parent_entity_id = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("sync_config_id", "entity_type", "entity_id"),
|
||||
)
|
||||
|
||||
sync_config = relationship("SyncConfig", back_populates="filters")
|
||||
|
||||
|
||||
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"),
|
||||
Index("ix_cached_stores_user_id", "user_id"),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="cached_stores")
|
||||
|
||||
|
||||
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"),
|
||||
Index("ix_cached_groups_user_store", "user_id", "store_evotor_id"),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="cached_groups")
|
||||
|
||||
|
||||
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"),
|
||||
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
|
||||
)
|
||||
|
||||
user = relationship("User", back_populates="cached_products")
|
||||
2001
web/package-lock.json
generated
Normal file
2001
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
web/package.json
Normal file
28
web/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "evosync-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"hono": "^4.7.4",
|
||||
"hono-sessions": "^0.5.5",
|
||||
"mysql2": "^3.14.0",
|
||||
"nunjucks": "^3.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.13.13",
|
||||
"@types/nunjucks": "^3.2.6",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from web.templates_env import templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth import hash_password, verify_password, get_current_user
|
||||
from web.config import settings
|
||||
from web.database import get_db
|
||||
from web.models import User
|
||||
from web.schemas import validate_registration, validate_login
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/register")
|
||||
def register_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if user:
|
||||
return RedirectResponse("/profile", 303)
|
||||
return templates.TemplateResponse("register.html", {"request": request, "user": None})
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register_submit(request: Request, db: Session = Depends(get_db)):
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
|
||||
errors = validate_registration(data)
|
||||
|
||||
if not errors:
|
||||
existing = db.query(User).filter(
|
||||
(User.email == data["email"].strip()) | (User.phone == data["phone"].strip())
|
||||
).first()
|
||||
if existing:
|
||||
if existing.email == data["email"].strip():
|
||||
errors.append("Пользователь с таким email уже существует")
|
||||
else:
|
||||
errors.append("Пользователь с таким телефоном уже существует")
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse("register.html", {
|
||||
"request": request, "user": None, "errors": errors, "form": data,
|
||||
})
|
||||
|
||||
token = uuid.uuid4().hex
|
||||
user = User(
|
||||
first_name=data["first_name"].strip(),
|
||||
last_name=data["last_name"].strip(),
|
||||
email=data["email"].strip(),
|
||||
phone=data["phone"].strip(),
|
||||
password_hash=hash_password(data["password"]),
|
||||
email_confirm_token=token,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
|
||||
print("=" * 40)
|
||||
print("ПОДТВЕРЖДЕНИЕ EMAIL")
|
||||
print(f"Пользователь: {user.email}")
|
||||
print(f"Ссылка: {confirm_url}")
|
||||
print("=" * 40)
|
||||
|
||||
return templates.TemplateResponse("confirm_email.html", {"request": request, "user": None})
|
||||
|
||||
|
||||
@router.get("/confirm-email")
|
||||
def confirm_email(request: Request, token: str, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.email_confirm_token == token).first()
|
||||
if not user:
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||
})
|
||||
|
||||
user.is_email_confirmed = True
|
||||
user.email_confirm_token = None
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse("email_confirmed.html", {"request": request, "user": None})
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def login_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if user:
|
||||
return RedirectResponse("/profile", 303)
|
||||
return templates.TemplateResponse("login.html", {"request": request, "user": None})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(request: Request, db: Session = Depends(get_db)):
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
|
||||
errors = validate_login(data)
|
||||
if errors:
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "user": None, "errors": errors, "form": data,
|
||||
})
|
||||
|
||||
user = db.query(User).filter(User.email == data["email"].strip()).first()
|
||||
if not user or not verify_password(data["password"], user.password_hash):
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "user": None,
|
||||
"errors": ["Неверный email или пароль"], "form": data,
|
||||
})
|
||||
|
||||
if not user.is_email_confirmed:
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "user": None,
|
||||
"errors": ["Пожалуйста, подтвердите ваш email"], "form": data,
|
||||
})
|
||||
|
||||
request.session["user_id"] = user.id
|
||||
return RedirectResponse("/profile", 303)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login", 303)
|
||||
@@ -1,308 +0,0 @@
|
||||
import csv
|
||||
import io
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
from web.templates_env import templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth import get_current_user
|
||||
from web.database import get_db
|
||||
from web.evotor_api import refresh_catalog_cache
|
||||
from web.models import User, EvotorConnection, SyncConfig, SyncFilter, CachedStore, CachedGroup, CachedProduct
|
||||
|
||||
router = APIRouter(prefix="/catalog")
|
||||
|
||||
|
||||
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
|
||||
config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first()
|
||||
if not config:
|
||||
config = SyncConfig(user_id=user_id, is_enabled=False)
|
||||
db.add(config)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
def _filter_map(config: SyncConfig) -> dict:
|
||||
"""Returns {entity_id: filter_mode} for quick lookup."""
|
||||
return {f.entity_id: f.filter_mode for f in config.filters}
|
||||
|
||||
|
||||
def _filter_label(mode: str | None) -> str:
|
||||
if mode == "include":
|
||||
return "include"
|
||||
if mode == "exclude":
|
||||
return "exclude"
|
||||
return "none"
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def catalog_stores(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||
if not evotor:
|
||||
return templates.TemplateResponse("catalog_stores.html", {
|
||||
"request": request, "user": user,
|
||||
"evotor": None, "stores": [], "filter_map": {}, "fetched_at": None,
|
||||
})
|
||||
|
||||
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
|
||||
|
||||
# Auto-refresh if cache is empty
|
||||
if not stores:
|
||||
await refresh_catalog_cache(user.id, evotor.access_token, db)
|
||||
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
|
||||
|
||||
config = _get_or_create_sync_config(db, user.id)
|
||||
fmap = _filter_map(config)
|
||||
fetched_at = stores[0].fetched_at if stores else None
|
||||
|
||||
return templates.TemplateResponse("catalog_stores.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"evotor": evotor,
|
||||
"stores": stores,
|
||||
"filter_map": fmap,
|
||||
"fetched_at": fetched_at,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
def catalog_groups(
|
||||
request: Request,
|
||||
store_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
store = db.query(CachedStore).filter(
|
||||
CachedStore.user_id == user.id,
|
||||
CachedStore.evotor_id == store_id,
|
||||
).first()
|
||||
if not store:
|
||||
return RedirectResponse("/catalog", 303)
|
||||
|
||||
groups = db.query(CachedGroup).filter(
|
||||
CachedGroup.user_id == user.id,
|
||||
CachedGroup.store_evotor_id == store_id,
|
||||
).order_by(CachedGroup.name).all()
|
||||
|
||||
# Count products per group
|
||||
product_counts = {}
|
||||
for g in groups:
|
||||
product_counts[g.evotor_id] = db.query(CachedProduct).filter(
|
||||
CachedProduct.user_id == user.id,
|
||||
CachedProduct.group_evotor_id == g.evotor_id,
|
||||
).count()
|
||||
|
||||
config = _get_or_create_sync_config(db, user.id)
|
||||
fmap = _filter_map(config)
|
||||
|
||||
return templates.TemplateResponse("catalog_groups.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"store": store,
|
||||
"groups": groups,
|
||||
"product_counts": product_counts,
|
||||
"filter_map": fmap,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/products")
|
||||
def catalog_products(
|
||||
request: Request,
|
||||
store_id: str,
|
||||
group_id: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
store = db.query(CachedStore).filter(
|
||||
CachedStore.user_id == user.id,
|
||||
CachedStore.evotor_id == store_id,
|
||||
).first()
|
||||
if not store:
|
||||
return RedirectResponse("/catalog", 303)
|
||||
|
||||
group = None
|
||||
query = db.query(CachedProduct).filter(
|
||||
CachedProduct.user_id == user.id,
|
||||
CachedProduct.store_evotor_id == store_id,
|
||||
)
|
||||
if group_id:
|
||||
group = db.query(CachedGroup).filter(
|
||||
CachedGroup.user_id == user.id,
|
||||
CachedGroup.evotor_id == group_id,
|
||||
).first()
|
||||
query = query.filter(CachedProduct.group_evotor_id == group_id)
|
||||
|
||||
products = query.order_by(CachedProduct.name).all()
|
||||
|
||||
config = _get_or_create_sync_config(db, user.id)
|
||||
fmap = _filter_map(config)
|
||||
|
||||
return templates.TemplateResponse("catalog_products.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"store": store,
|
||||
"group": group,
|
||||
"products": products,
|
||||
"filter_map": fmap,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/filter")
|
||||
async def catalog_filter(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
form = await request.form()
|
||||
entity_type = form.get("entity_type")
|
||||
entity_id = form.get("entity_id")
|
||||
entity_name = form.get("entity_name")
|
||||
filter_mode = form.get("filter_mode") # "include", "exclude", "none"
|
||||
parent_entity_id = form.get("parent_entity_id") or None
|
||||
redirect_to = form.get("redirect_to", "/catalog")
|
||||
|
||||
config = _get_or_create_sync_config(db, user.id)
|
||||
|
||||
existing = db.query(SyncFilter).filter(
|
||||
SyncFilter.sync_config_id == config.id,
|
||||
SyncFilter.entity_type == entity_type,
|
||||
SyncFilter.entity_id == entity_id,
|
||||
).first()
|
||||
|
||||
if filter_mode == "none":
|
||||
if existing:
|
||||
db.delete(existing)
|
||||
elif existing:
|
||||
existing.filter_mode = filter_mode
|
||||
existing.entity_name = entity_name
|
||||
else:
|
||||
db.add(SyncFilter(
|
||||
sync_config_id=config.id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
filter_mode=filter_mode,
|
||||
parent_entity_id=parent_entity_id,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse(redirect_to, 303)
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def catalog_refresh(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||
if evotor:
|
||||
await refresh_catalog_cache(user.id, evotor.access_token, db)
|
||||
|
||||
return RedirectResponse("/catalog", 303)
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def catalog_export(
|
||||
request: Request,
|
||||
type: str,
|
||||
store_id: str | None = None,
|
||||
group_id: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
config = _get_or_create_sync_config(db, user.id)
|
||||
fmap = _filter_map(config)
|
||||
|
||||
def filter_label(eid):
|
||||
m = fmap.get(eid)
|
||||
if m == "include":
|
||||
return "Включено"
|
||||
if m == "exclude":
|
||||
return "Исключено"
|
||||
return "Нет правила"
|
||||
|
||||
output = io.StringIO()
|
||||
output.write("\ufeff") # UTF-8 BOM for Excel
|
||||
writer = csv.writer(output)
|
||||
|
||||
from datetime import date
|
||||
today = date.today().strftime("%Y%m%d")
|
||||
|
||||
if type == "stores":
|
||||
writer.writerow(["Название", "Адрес", "ID", "Фильтр"])
|
||||
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
|
||||
for s in stores:
|
||||
writer.writerow([s.name, s.address or "", s.evotor_id, filter_label(s.evotor_id)])
|
||||
filename = f"stores_{today}.csv"
|
||||
|
||||
elif type == "groups":
|
||||
writer.writerow(["Магазин", "Название", "ID", "Фильтр"])
|
||||
q = db.query(CachedGroup, CachedStore).join(
|
||||
CachedStore,
|
||||
(CachedStore.evotor_id == CachedGroup.store_evotor_id) & (CachedStore.user_id == user.id)
|
||||
).filter(CachedGroup.user_id == user.id)
|
||||
if store_id:
|
||||
q = q.filter(CachedGroup.store_evotor_id == store_id)
|
||||
for g, s in q.order_by(CachedGroup.name).all():
|
||||
writer.writerow([s.name, g.name, g.evotor_id, filter_label(g.evotor_id)])
|
||||
filename = f"groups_{today}.csv"
|
||||
|
||||
else: # products
|
||||
writer.writerow(["Магазин", "Группа", "Название", "Артикул", "Цена", "Количество", "Ед. измерения", "В продаже", "ID", "Фильтр"])
|
||||
q = db.query(CachedProduct, CachedStore, CachedGroup).join(
|
||||
CachedStore,
|
||||
(CachedStore.evotor_id == CachedProduct.store_evotor_id) & (CachedStore.user_id == user.id)
|
||||
).outerjoin(
|
||||
CachedGroup,
|
||||
(CachedGroup.evotor_id == CachedProduct.group_evotor_id) & (CachedGroup.user_id == user.id)
|
||||
).filter(CachedProduct.user_id == user.id)
|
||||
if store_id:
|
||||
q = q.filter(CachedProduct.store_evotor_id == store_id)
|
||||
if group_id:
|
||||
q = q.filter(CachedProduct.group_evotor_id == group_id)
|
||||
for p, s, g in q.order_by(CachedProduct.name).all():
|
||||
writer.writerow([
|
||||
s.name,
|
||||
g.name if g else "",
|
||||
p.name,
|
||||
p.article_number or "",
|
||||
p.price or "",
|
||||
p.quantity or "",
|
||||
p.measure_name or "",
|
||||
"Да" if p.allow_to_sell else ("Нет" if p.allow_to_sell is not None else ""),
|
||||
p.evotor_id,
|
||||
filter_label(p.evotor_id),
|
||||
])
|
||||
filename = f"products_{today}.csv"
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
@@ -1,125 +0,0 @@
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from web.templates_env import templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth import get_current_user
|
||||
from web.database import get_db
|
||||
from web.models import User, EvotorConnection, VkConnection
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SERVICE_TYPES = [
|
||||
{
|
||||
"type": "evotor",
|
||||
"name": "Эвотор",
|
||||
"icon": "bi-shop",
|
||||
"description": "Подключите кассу Эвотор для синхронизации каталога товаров.",
|
||||
"configure_url": "/evotor",
|
||||
"connect_url": "/evotor",
|
||||
},
|
||||
{
|
||||
"type": "vk",
|
||||
"name": "ВКонтакте",
|
||||
"icon": "bi-bag",
|
||||
"description": "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.",
|
||||
"configure_url": "/vk",
|
||||
"connect_url": "/vk",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _get_connection(svc_type: str, evotor, vk):
|
||||
if svc_type == "evotor":
|
||||
return evotor
|
||||
if svc_type == "vk":
|
||||
return vk
|
||||
return None
|
||||
|
||||
|
||||
def _get_details(svc_type: str, conn):
|
||||
if conn is None:
|
||||
return None
|
||||
if svc_type == "evotor":
|
||||
return conn.store_name
|
||||
if svc_type == "vk":
|
||||
return f"{conn.first_name} {conn.last_name}".strip() if conn.first_name else None
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/connections")
|
||||
def connections_page(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||
vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||
|
||||
connected = []
|
||||
for svc in SERVICE_TYPES:
|
||||
conn = _get_connection(svc["type"], evotor, vk)
|
||||
if conn is not None:
|
||||
connected.append({
|
||||
**svc,
|
||||
"is_online": conn.is_online,
|
||||
"last_checked_at": conn.last_checked_at,
|
||||
"details": _get_details(svc["type"], conn),
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("connections.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"connections": connected,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/connections/add")
|
||||
def connections_add_page(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||
vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||
|
||||
available = [
|
||||
svc for svc in SERVICE_TYPES
|
||||
if _get_connection(svc["type"], evotor, vk) is None
|
||||
]
|
||||
|
||||
return templates.TemplateResponse("connections_add.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"available": available,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/connections/delete")
|
||||
async def connections_delete(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
svc_type = request.query_params.get("type")
|
||||
if svc_type == "evotor":
|
||||
conn = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||
elif svc_type == "vk":
|
||||
conn = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||
else:
|
||||
conn = None
|
||||
|
||||
if conn:
|
||||
db.delete(conn)
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse("/connections", 303)
|
||||
@@ -1,193 +0,0 @@
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from web.templates_env import templates
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth import get_current_user
|
||||
from web.config import settings
|
||||
from web.database import get_db
|
||||
from web.models import User, EvotorConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/evotor")
|
||||
|
||||
EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"
|
||||
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
||||
|
||||
|
||||
@router.get("")
|
||||
def evotor_page(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||
error = request.query_params.get("error")
|
||||
app_url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID) if settings.EVOTOR_APP_ID else None
|
||||
return templates.TemplateResponse("evotor.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"connection": connection,
|
||||
"error": error,
|
||||
"app_url": app_url,
|
||||
})
|
||||
|
||||
|
||||
class EvotorTokenPayload(BaseModel):
|
||||
userId: str
|
||||
token: str
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def evotor_callback(
|
||||
request: Request,
|
||||
payload: EvotorTokenPayload,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Webhook endpoint: Evotor POSTs {"userId": "...", "token": "..."} here
|
||||
after the user authorizes the app in their Evotor account.
|
||||
"""
|
||||
# Verify the Authorization header matches our configured webhook secret
|
||||
if settings.EVOTOR_WEBHOOK_SECRET:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
expected = f"Bearer {settings.EVOTOR_WEBHOOK_SECRET}"
|
||||
if auth_header != expected:
|
||||
logger.warning("Evotor webhook: invalid Authorization header")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Fetch store info using the received token
|
||||
store_id = None
|
||||
store_name = None
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
stores_response = await client.get(
|
||||
EVOTOR_STORES_URL,
|
||||
headers={"Authorization": f"Bearer {payload.token}"},
|
||||
timeout=15,
|
||||
)
|
||||
if stores_response.status_code == 200:
|
||||
stores = stores_response.json()
|
||||
items = stores.get("items", stores) if isinstance(stores, dict) else stores
|
||||
if items:
|
||||
store_id = items[0].get("uuid") or items[0].get("id")
|
||||
store_name = items[0].get("name")
|
||||
except Exception:
|
||||
pass # Store info is optional
|
||||
|
||||
# Upsert by evotor_user_id (user_id stays NULL until /evotor/link is called)
|
||||
connection = db.query(EvotorConnection).filter(
|
||||
EvotorConnection.evotor_user_id == payload.userId
|
||||
).first()
|
||||
|
||||
if connection:
|
||||
connection.access_token = payload.token
|
||||
connection.store_id = store_id
|
||||
connection.store_name = store_name
|
||||
connection.is_online = True
|
||||
connection.last_checked_at = now
|
||||
connection.updated_at = now
|
||||
else:
|
||||
connection = EvotorConnection(
|
||||
evotor_user_id=payload.userId,
|
||||
access_token=payload.token,
|
||||
store_id=store_id,
|
||||
store_name=store_name,
|
||||
is_online=True,
|
||||
last_checked_at=now,
|
||||
)
|
||||
db.add(connection)
|
||||
|
||||
db.commit()
|
||||
logger.info("Evotor webhook: saved token for evotor_user_id=%s", payload.userId)
|
||||
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def evotor_token_manual(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
"""Allow user to manually paste their Evotor token."""
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
form = await request.form()
|
||||
token = (form.get("token") or "").strip()
|
||||
if not token:
|
||||
return RedirectResponse("/evotor?error=empty_token", 303)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Fetch store info
|
||||
store_id = None
|
||||
store_name = None
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
stores_response = await client.get(
|
||||
EVOTOR_STORES_URL,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=15,
|
||||
)
|
||||
if stores_response.status_code == 200:
|
||||
stores = stores_response.json()
|
||||
items = stores.get("items", stores) if isinstance(stores, dict) else stores
|
||||
if items:
|
||||
store_id = items[0].get("uuid") or items[0].get("id")
|
||||
store_name = items[0].get("name")
|
||||
elif stores_response.status_code == 401:
|
||||
return RedirectResponse("/evotor?error=invalid_token", 303)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||
if connection:
|
||||
connection.access_token = token
|
||||
connection.store_id = store_id
|
||||
connection.store_name = store_name
|
||||
connection.is_online = True
|
||||
connection.last_checked_at = now
|
||||
connection.updated_at = now
|
||||
else:
|
||||
connection = EvotorConnection(
|
||||
user_id=user.id,
|
||||
access_token=token,
|
||||
store_id=store_id,
|
||||
store_name=store_name,
|
||||
is_online=True,
|
||||
last_checked_at=now,
|
||||
)
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse("/connections", 303)
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
async def evotor_disconnect(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||
if connection:
|
||||
db.delete(connection)
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse("/connections", 303)
|
||||
@@ -1,144 +0,0 @@
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from web.templates_env import templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth import get_current_user, verify_password, hash_password
|
||||
from web.database import get_db
|
||||
from web.models import User
|
||||
from web.schemas import validate_profile, validate_reset_password
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# VIEW PROFILE
|
||||
@router.get("/profile")
|
||||
def profile_view(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
return templates.TemplateResponse("profile_view.html", {"request": request, "user": user})
|
||||
|
||||
|
||||
# EDIT PROFILE
|
||||
@router.get("/profile/edit")
|
||||
def profile_edit_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
return templates.TemplateResponse("profile_edit.html", {"request": request, "user": user})
|
||||
|
||||
|
||||
@router.post("/profile/edit")
|
||||
async def profile_edit_submit(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
|
||||
errors = validate_profile(data)
|
||||
|
||||
if not errors:
|
||||
existing = db.query(User).filter(
|
||||
User.phone == data["phone"].strip(), User.id != user.id
|
||||
).first()
|
||||
if existing:
|
||||
errors.append("Пользователь с таким телефоном уже существует")
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse("profile_edit.html", {
|
||||
"request": request, "user": user, "errors": errors, "form": data,
|
||||
})
|
||||
|
||||
user.first_name = data["first_name"].strip()
|
||||
user.last_name = data["last_name"].strip()
|
||||
user.phone = data["phone"].strip()
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse("profile_edit.html", {
|
||||
"request": request, "user": user, "success": "Профиль обновлен",
|
||||
})
|
||||
|
||||
|
||||
# CHANGE PASSWORD
|
||||
@router.get("/profile/change-password")
|
||||
def change_password_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
return templates.TemplateResponse("profile_change_password.html", {"request": request, "user": user})
|
||||
|
||||
|
||||
@router.post("/profile/change-password")
|
||||
async def change_password_submit(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
|
||||
errors = []
|
||||
current_password = data.get("current_password", "")
|
||||
if not current_password:
|
||||
errors.append("Введите текущий пароль")
|
||||
elif not verify_password(current_password, user.password_hash):
|
||||
errors.append("Неверный текущий пароль")
|
||||
|
||||
password_errors = validate_reset_password(data)
|
||||
errors.extend(password_errors)
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse("profile_change_password.html", {
|
||||
"request": request, "user": user, "errors": errors,
|
||||
})
|
||||
|
||||
user.password_hash = hash_password(data["password"])
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse("profile_change_password.html", {
|
||||
"request": request, "user": user, "success": "Пароль изменен",
|
||||
})
|
||||
|
||||
|
||||
# DELETE ACCOUNT
|
||||
@router.get("/profile/delete")
|
||||
def delete_account_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
return templates.TemplateResponse("profile_delete.html", {"request": request, "user": user})
|
||||
|
||||
|
||||
@router.post("/profile/delete")
|
||||
async def delete_account_submit(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
|
||||
password = data.get("password", "")
|
||||
if not password:
|
||||
return templates.TemplateResponse("profile_delete.html", {
|
||||
"request": request, "user": user, "errors": ["Введите пароль для подтверждения"],
|
||||
})
|
||||
|
||||
if not verify_password(password, user.password_hash):
|
||||
return templates.TemplateResponse("profile_delete.html", {
|
||||
"request": request, "user": user, "errors": ["Неверный пароль"],
|
||||
})
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
request.session.clear()
|
||||
|
||||
return RedirectResponse("/", 303)
|
||||
@@ -1,107 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from web.templates_env import templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth import hash_password
|
||||
from web.config import settings
|
||||
from web.database import get_db
|
||||
from web.models import User
|
||||
from web.schemas import validate_reset_password
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/forgot-password")
|
||||
def forgot_form(request: Request):
|
||||
return templates.TemplateResponse("forgot_password.html", {"request": request, "user": None})
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_submit(request: Request, db: Session = Depends(get_db)):
|
||||
form = await request.form()
|
||||
email = form.get("email", "").strip()
|
||||
|
||||
if email:
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if user:
|
||||
token = uuid.uuid4().hex
|
||||
user.password_reset_token = token
|
||||
user.password_reset_expires = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
|
||||
)
|
||||
db.commit()
|
||||
|
||||
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
|
||||
print("=" * 40)
|
||||
print("СБРОС ПАРОЛЯ")
|
||||
print(f"Пользователь: {user.email}")
|
||||
print(f"Ссылка: {reset_url}")
|
||||
print(f"Действительна: {settings.PASSWORD_RESET_EXPIRE_MINUTES} мин.")
|
||||
print("=" * 40)
|
||||
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Сброс пароля",
|
||||
"message": "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/reset-password")
|
||||
def reset_form(request: Request, token: str, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.password_reset_token == token).first()
|
||||
if not user or not user.password_reset_expires:
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||
})
|
||||
|
||||
if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc):
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Ошибка", "message": "Срок действия ссылки истек.",
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("reset_password.html", {
|
||||
"request": request, "user": None, "token": token,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_submit(request: Request, token: str, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.password_reset_token == token).first()
|
||||
if not user or not user.password_reset_expires:
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||
})
|
||||
|
||||
if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc):
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Ошибка", "message": "Срок действия ссылки истек.",
|
||||
})
|
||||
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
errors = validate_reset_password(data)
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse("reset_password.html", {
|
||||
"request": request, "user": None, "token": token, "errors": errors,
|
||||
})
|
||||
|
||||
user.password_hash = hash_password(data["password"])
|
||||
user.password_reset_token = None
|
||||
user.password_reset_expires = None
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Пароль изменен",
|
||||
"message": "Ваш пароль успешно изменен. Теперь вы можете войти.",
|
||||
"link": "/login", "link_text": "Войти",
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from web.templates_env import templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth import get_current_user
|
||||
from web.database import get_db
|
||||
from web.models import User, EvotorConnection, VkConnection, SyncConfig, SyncFilter
|
||||
|
||||
router = APIRouter(prefix="/sync")
|
||||
|
||||
|
||||
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
|
||||
config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first()
|
||||
if not config:
|
||||
config = SyncConfig(user_id=user_id, is_enabled=False)
|
||||
db.add(config)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
def _filter_summary(config: SyncConfig) -> dict:
|
||||
stores = [f for f in config.filters if f.entity_type == "store"]
|
||||
groups = [f for f in config.filters if f.entity_type == "group"]
|
||||
products = [f for f in config.filters if f.entity_type == "product"]
|
||||
return {
|
||||
"stores": len(stores),
|
||||
"groups": len(groups),
|
||||
"products": len(products),
|
||||
"total": len(config.filters),
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def sync_page(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||
vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||
config = _get_or_create_sync_config(db, user.id)
|
||||
summary = _filter_summary(config)
|
||||
|
||||
if config.confirmed_at and config.is_enabled:
|
||||
status = "active"
|
||||
elif config.confirmed_at and not config.is_enabled:
|
||||
status = "paused"
|
||||
elif summary["total"] > 0:
|
||||
status = "pending"
|
||||
else:
|
||||
status = "unconfigured"
|
||||
|
||||
return templates.TemplateResponse("sync.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"evotor": evotor,
|
||||
"vk": vk,
|
||||
"config": config,
|
||||
"summary": summary,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/toggle")
|
||||
def sync_toggle(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
config = _get_or_create_sync_config(db, user.id)
|
||||
config.is_enabled = not config.is_enabled
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse("/sync", 303)
|
||||
|
||||
|
||||
@router.post("/confirm")
|
||||
def sync_confirm(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
config = _get_or_create_sync_config(db, user.id)
|
||||
if config.is_enabled and len(config.filters) > 0:
|
||||
config.confirmed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse("/sync", 303)
|
||||
168
web/routes/vk.py
168
web/routes/vk.py
@@ -1,168 +0,0 @@
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from web.templates_env import templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth import get_current_user
|
||||
from web.config import settings
|
||||
from web.database import get_db
|
||||
from web.models import User, VkConnection
|
||||
|
||||
router = APIRouter(prefix="/vk")
|
||||
|
||||
VK_API_URL = "https://api.vk.com/method"
|
||||
VK_OAUTH_URL = "https://oauth.vk.com/authorize"
|
||||
|
||||
|
||||
async def _fetch_group_info(token: str) -> tuple[str | None, str | None]:
|
||||
"""Returns (group_id, group_name) for the first admin group, or (None, None)."""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{VK_API_URL}/groups.get",
|
||||
params={
|
||||
"access_token": token,
|
||||
"v": settings.VK_API_VERSION,
|
||||
"filter": "admin",
|
||||
"extended": 1,
|
||||
"count": 1,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if "error" not in data:
|
||||
items = data.get("response", {}).get("items", [])
|
||||
if items:
|
||||
return str(items[0].get("id", "")), items[0].get("name")
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
def _save_connection(db: Session, user_id: int, token: str,
|
||||
group_id: str | None, group_name: str | None) -> None:
|
||||
now = datetime.utcnow()
|
||||
connection = db.query(VkConnection).filter(VkConnection.user_id == user_id).first()
|
||||
if connection:
|
||||
connection.access_token = token
|
||||
connection.vk_user_id = group_id
|
||||
connection.first_name = group_name
|
||||
connection.last_name = None
|
||||
connection.is_online = True
|
||||
connection.last_checked_at = now
|
||||
else:
|
||||
db.add(VkConnection(
|
||||
user_id=user_id,
|
||||
access_token=token,
|
||||
vk_user_id=group_id,
|
||||
first_name=group_name,
|
||||
last_name=None,
|
||||
is_online=True,
|
||||
last_checked_at=now,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def vk_page(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||
error = request.query_params.get("error")
|
||||
return templates.TemplateResponse("vk.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"connection": connection,
|
||||
"error": error,
|
||||
"vk_client_id": settings.VK_CLIENT_ID,
|
||||
"callback_url": f"{settings.BASE_URL}/vk/callback",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/connect")
|
||||
def vk_connect(
|
||||
request: Request,
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
"""Redirect to VK OAuth authorization page."""
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
if not settings.VK_CLIENT_ID:
|
||||
return RedirectResponse("/vk?error=no_client_id", 303)
|
||||
|
||||
params = urlencode({
|
||||
"client_id": settings.VK_CLIENT_ID,
|
||||
"scope": "market,groups",
|
||||
"redirect_uri": f"{settings.BASE_URL}/vk/callback",
|
||||
"display": "page",
|
||||
"response_type": "token",
|
||||
"v": settings.VK_API_VERSION,
|
||||
})
|
||||
return RedirectResponse(f"{VK_OAUTH_URL}?{params}", 302)
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
def vk_callback(
|
||||
request: Request,
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
"""Landing page after VK OAuth. JS reads the token from the URL fragment and POSTs it."""
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
return templates.TemplateResponse("vk_callback.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def vk_token(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
"""Save a VK user access token (from manual entry or OAuth callback)."""
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
form = await request.form()
|
||||
token = (form.get("token") or "").strip()
|
||||
if not token:
|
||||
return RedirectResponse("/vk?error=empty_token", 303)
|
||||
|
||||
group_id, group_name = await _fetch_group_info(token)
|
||||
if not group_id:
|
||||
return RedirectResponse("/vk?error=invalid_token", 303)
|
||||
|
||||
_save_connection(db, user.id, token, group_id, group_name)
|
||||
return RedirectResponse("/connections", 303)
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
async def vk_disconnect(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||
if connection:
|
||||
db.delete(connection)
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse("/connections", 303)
|
||||
@@ -1,52 +0,0 @@
|
||||
import re
|
||||
|
||||
|
||||
def validate_registration(data: dict) -> list[str]:
|
||||
errors = []
|
||||
if not data.get("first_name", "").strip():
|
||||
errors.append("Введите имя")
|
||||
if not data.get("last_name", "").strip():
|
||||
errors.append("Введите фамилию")
|
||||
email = data.get("email", "").strip()
|
||||
if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email):
|
||||
errors.append("Введите корректный email")
|
||||
phone = data.get("phone", "").strip()
|
||||
if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
|
||||
errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
|
||||
password = data.get("password", "")
|
||||
if len(password) < 8:
|
||||
errors.append("Пароль должен быть не менее 8 символов")
|
||||
if password != data.get("password_confirm", ""):
|
||||
errors.append("Пароли не совпадают")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_login(data: dict) -> list[str]:
|
||||
errors = []
|
||||
if not data.get("email", "").strip():
|
||||
errors.append("Введите email")
|
||||
if not data.get("password", ""):
|
||||
errors.append("Введите пароль")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_reset_password(data: dict) -> list[str]:
|
||||
errors = []
|
||||
password = data.get("password", "")
|
||||
if len(password) < 8:
|
||||
errors.append("Пароль должен быть не менее 8 символов")
|
||||
if password != data.get("password_confirm", ""):
|
||||
errors.append("Пароли не совпадают")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_profile(data: dict) -> list[str]:
|
||||
errors = []
|
||||
if not data.get("first_name", "").strip():
|
||||
errors.append("Введите имя")
|
||||
if not data.get("last_name", "").strip():
|
||||
errors.append("Введите фамилию")
|
||||
phone = data.get("phone", "").strip()
|
||||
if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
|
||||
errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
|
||||
return errors
|
||||
22
web/src/config.ts
Normal file
22
web/src/config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const config = {
|
||||
DATABASE_URL: process.env.DATABASE_URL ?? "mysql://evosync:evosync@localhost:3306/evosync",
|
||||
SECRET_KEY: process.env.SECRET_KEY ?? "change-me-in-production",
|
||||
BASE_URL: process.env.BASE_URL ?? "http://localhost:8080",
|
||||
PORT: parseInt(process.env.PORT ?? "3000", 10),
|
||||
|
||||
PASSWORD_RESET_EXPIRE_MINUTES: parseInt(process.env.PASSWORD_RESET_EXPIRE_MINUTES ?? "60", 10),
|
||||
|
||||
EVOTOR_APP_ID: process.env.EVOTOR_APP_ID ?? "",
|
||||
EVOTOR_WEBHOOK_SECRET: process.env.EVOTOR_WEBHOOK_SECRET ?? "",
|
||||
|
||||
JIVOSITE_WIDGET_ID: process.env.JIVOSITE_WIDGET_ID ?? "",
|
||||
|
||||
HEALTH_CHECK_INTERVAL_SECONDS: parseInt(process.env.HEALTH_CHECK_INTERVAL_SECONDS ?? "600", 10),
|
||||
CATALOG_REFRESH_INTERVAL_SECONDS: parseInt(process.env.CATALOG_REFRESH_INTERVAL_SECONDS ?? "3600", 10),
|
||||
SYNC_INTERVAL_SECONDS: parseInt(process.env.SYNC_INTERVAL_SECONDS ?? "3600", 10),
|
||||
|
||||
VK_DEFAULT_PHOTO_PATH: process.env.VK_DEFAULT_PHOTO_PATH ?? "/app/default_product.png",
|
||||
VK_CLIENT_ID: process.env.VK_CLIENT_ID ?? "",
|
||||
VK_CLIENT_SECRET: process.env.VK_CLIENT_SECRET ?? "",
|
||||
VK_API_VERSION: process.env.VK_API_VERSION ?? "5.131",
|
||||
} as const;
|
||||
13
web/src/db/client.ts
Normal file
13
web/src/db/client.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import mysql from "mysql2/promise";
|
||||
import { config } from "../config.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
const pool = mysql.createPool({
|
||||
uri: config.DATABASE_URL,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
decimalNumbers: true,
|
||||
});
|
||||
|
||||
export const db = drizzle(pool, { schema, mode: "default" });
|
||||
140
web/src/db/schema.ts
Normal file
140
web/src/db/schema.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
int,
|
||||
varchar,
|
||||
text,
|
||||
boolean,
|
||||
datetime,
|
||||
decimal,
|
||||
uniqueIndex,
|
||||
index,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = mysqlTable("users", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
first_name: varchar("first_name", { length: 100 }).notNull(),
|
||||
last_name: varchar("last_name", { length: 100 }).notNull(),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
phone: varchar("phone", { length: 20 }).notNull(),
|
||||
password_hash: varchar("password_hash", { length: 255 }).notNull(),
|
||||
is_email_confirmed: boolean("is_email_confirmed").notNull().default(false),
|
||||
email_confirm_token: varchar("email_confirm_token", { length: 255 }),
|
||||
password_reset_token: varchar("password_reset_token", { length: 255 }),
|
||||
password_reset_expires: datetime("password_reset_expires"),
|
||||
created_at: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (t) => [
|
||||
uniqueIndex("ix_users_email").on(t.email),
|
||||
uniqueIndex("ix_users_phone").on(t.phone),
|
||||
]);
|
||||
|
||||
export const evotor_connections = mysqlTable("evotor_connections", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
user_id: int("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
evotor_user_id: varchar("evotor_user_id", { length: 255 }),
|
||||
access_token: text("access_token").notNull(),
|
||||
store_id: varchar("store_id", { length: 255 }),
|
||||
store_name: varchar("store_name", { length: 255 }),
|
||||
refresh_token: text("refresh_token"),
|
||||
token_expires_at: datetime("token_expires_at"),
|
||||
is_online: boolean("is_online").notNull().default(false),
|
||||
last_checked_at: datetime("last_checked_at"),
|
||||
connected_at: datetime("connected_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (t) => [
|
||||
uniqueIndex("ix_evotor_connections_user_id").on(t.user_id),
|
||||
uniqueIndex("ix_evotor_connections_evotor_user_id").on(t.evotor_user_id),
|
||||
]);
|
||||
|
||||
export const vk_connections = mysqlTable("vk_connections", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
access_token: text("access_token").notNull(),
|
||||
vk_user_id: varchar("vk_user_id", { length: 50 }),
|
||||
first_name: varchar("first_name", { length: 255 }),
|
||||
last_name: varchar("last_name", { length: 255 }),
|
||||
is_online: boolean("is_online").notNull().default(false),
|
||||
last_checked_at: datetime("last_checked_at"),
|
||||
connected_at: datetime("connected_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (t) => [
|
||||
uniqueIndex("ix_vk_connections_user_id").on(t.user_id),
|
||||
]);
|
||||
|
||||
export const sync_configs = mysqlTable("sync_configs", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
is_enabled: boolean("is_enabled").notNull().default(false),
|
||||
confirmed_at: datetime("confirmed_at"),
|
||||
created_at: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updated_at: datetime("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (t) => [
|
||||
uniqueIndex("ix_sync_configs_user_id").on(t.user_id),
|
||||
]);
|
||||
|
||||
export const sync_filters = mysqlTable("sync_filters", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
sync_config_id: int("sync_config_id").notNull().references(() => sync_configs.id, { onDelete: "cascade" }),
|
||||
entity_type: varchar("entity_type", { length: 20 }).notNull(),
|
||||
entity_id: varchar("entity_id", { length: 255 }).notNull(),
|
||||
entity_name: varchar("entity_name", { length: 255 }),
|
||||
filter_mode: varchar("filter_mode", { length: 10 }).notNull(),
|
||||
parent_entity_id: varchar("parent_entity_id", { length: 255 }),
|
||||
created_at: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (t) => [
|
||||
uniqueIndex("uq_sync_filters_config_type_entity").on(t.sync_config_id, t.entity_type, t.entity_id),
|
||||
]);
|
||||
|
||||
export const cached_stores = mysqlTable("cached_stores", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
evotor_id: varchar("evotor_id", { length: 255 }).notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
address: varchar("address", { length: 500 }),
|
||||
fetched_at: datetime("fetched_at").notNull(),
|
||||
}, (t) => [
|
||||
uniqueIndex("uq_cached_stores_user_evotor").on(t.user_id, t.evotor_id),
|
||||
index("ix_cached_stores_user_id").on(t.user_id),
|
||||
]);
|
||||
|
||||
export const cached_groups = mysqlTable("cached_groups", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
evotor_id: varchar("evotor_id", { length: 255 }).notNull(),
|
||||
store_evotor_id: varchar("store_evotor_id", { length: 255 }).notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
fetched_at: datetime("fetched_at").notNull(),
|
||||
}, (t) => [
|
||||
uniqueIndex("uq_cached_groups_user_evotor").on(t.user_id, t.evotor_id),
|
||||
index("ix_cached_groups_user_store").on(t.user_id, t.store_evotor_id),
|
||||
]);
|
||||
|
||||
export const cached_products = mysqlTable("cached_products", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
user_id: int("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
evotor_id: varchar("evotor_id", { length: 255 }).notNull(),
|
||||
store_evotor_id: varchar("store_evotor_id", { length: 255 }).notNull(),
|
||||
group_evotor_id: varchar("group_evotor_id", { length: 255 }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
price: decimal("price", { precision: 12, scale: 2 }),
|
||||
quantity: decimal("quantity", { precision: 12, scale: 3 }),
|
||||
measure_name: varchar("measure_name", { length: 20 }),
|
||||
article_number: varchar("article_number", { length: 100 }),
|
||||
allow_to_sell: boolean("allow_to_sell"),
|
||||
fetched_at: datetime("fetched_at").notNull(),
|
||||
synced_at: datetime("synced_at"),
|
||||
}, (t) => [
|
||||
uniqueIndex("uq_cached_products_user_evotor").on(t.user_id, t.evotor_id),
|
||||
index("ix_cached_products_user_store_group").on(t.user_id, t.store_evotor_id, t.group_evotor_id),
|
||||
]);
|
||||
|
||||
// Convenience types
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type EvotorConnection = typeof evotor_connections.$inferSelect;
|
||||
export type VkConnection = typeof vk_connections.$inferSelect;
|
||||
export type SyncConfig = typeof sync_configs.$inferSelect;
|
||||
export type SyncFilter = typeof sync_filters.$inferSelect;
|
||||
export type CachedStore = typeof cached_stores.$inferSelect;
|
||||
export type CachedGroup = typeof cached_groups.$inferSelect;
|
||||
export type CachedProduct = typeof cached_products.$inferSelect;
|
||||
71
web/src/index.ts
Normal file
71
web/src/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { Hono } from "hono";
|
||||
import { sessionMiddleware, CookieStore } from "hono-sessions";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import { config } from "./config.js";
|
||||
import { getSessionUserId, type AppEnv } from "./lib/auth.js";
|
||||
import { db } from "./db/client.js";
|
||||
import { users } from "./db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { startHealthCheckLoop } from "./lib/healthChecker.js";
|
||||
import { startSyncLoop } from "./lib/syncEngine.js";
|
||||
|
||||
import { authRouter } from "./routes/auth.js";
|
||||
import { resetRouter } from "./routes/reset.js";
|
||||
import { profileRouter } from "./routes/profile.js";
|
||||
import { connectionsRouter } from "./routes/connections.js";
|
||||
import { evotorRouter } from "./routes/evotor.js";
|
||||
import { vkRouter } from "./routes/vk.js";
|
||||
import { catalogRouter } from "./routes/catalog.js";
|
||||
import { syncRouter } from "./routes/sync.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
// Session middleware
|
||||
const store = new CookieStore();
|
||||
app.use("*", sessionMiddleware({
|
||||
store,
|
||||
encryptionKey: config.SECRET_KEY.padEnd(32, "0").slice(0, 32),
|
||||
expireAfterSeconds: 86400 * 30,
|
||||
cookieOptions: {
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
sessionCookieName: "session",
|
||||
}));
|
||||
|
||||
// Static files
|
||||
app.use("/static/*", serveStatic({ root: path.join(__dirname, "../") }));
|
||||
|
||||
// Routes
|
||||
app.route("/", authRouter);
|
||||
app.route("/", resetRouter);
|
||||
app.route("/", profileRouter);
|
||||
app.route("/", connectionsRouter);
|
||||
app.route("/", evotorRouter);
|
||||
app.route("/", vkRouter);
|
||||
app.route("/", catalogRouter);
|
||||
app.route("/", syncRouter);
|
||||
|
||||
// Home redirect
|
||||
app.get("/", async (c) => {
|
||||
const userId = getSessionUserId(c);
|
||||
if (userId) {
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (user) return c.redirect("/profile", 302);
|
||||
}
|
||||
return c.redirect("/login", 302);
|
||||
});
|
||||
|
||||
serve({ fetch: app.fetch, port: config.PORT }, () => {
|
||||
console.log(`Server running on port ${config.PORT}`);
|
||||
startHealthCheckLoop(config.HEALTH_CHECK_INTERVAL_SECONDS * 1000);
|
||||
startSyncLoop(config.SYNC_INTERVAL_SECONDS * 1000);
|
||||
});
|
||||
21
web/src/lib/auth.ts
Normal file
21
web/src/lib/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { Context } from "hono";
|
||||
import type { Session } from "hono-sessions";
|
||||
|
||||
// Hono env type that includes our session variable
|
||||
export type AppEnv = { Variables: { session: Session } };
|
||||
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
export function verifyPassword(plain: string, hashed: string): Promise<boolean> {
|
||||
return bcrypt.compare(plain, hashed);
|
||||
}
|
||||
|
||||
export function getSessionUserId(c: Context<AppEnv>): number | null {
|
||||
const session = c.get("session");
|
||||
if (!session) return null;
|
||||
const id = session.get("user_id");
|
||||
return typeof id === "number" ? id : null;
|
||||
}
|
||||
106
web/src/lib/evotorApi.ts
Normal file
106
web/src/lib/evotorApi.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { db } from "../db/client.js";
|
||||
import { cached_stores, cached_groups, cached_products } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const EVOTOR_API_BASE = "https://api.evotor.ru";
|
||||
|
||||
async function fetchJson(url: string, token: string, allowStatuses: number[] = []): Promise<unknown> {
|
||||
const resp = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (allowStatuses.includes(resp.status)) return null;
|
||||
if (!resp.ok) throw new Error(`Evotor API error ${resp.status}: ${url}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function fetchStores(token: string): Promise<Array<{ id: string; name: string; address?: string }>> {
|
||||
const data = await fetchJson(`${EVOTOR_API_BASE}/stores`, token) as unknown;
|
||||
const items = (typeof data === "object" && data !== null && "items" in data)
|
||||
? (data as { items: unknown[] }).items
|
||||
: (Array.isArray(data) ? data : []);
|
||||
return (items as Array<Record<string, unknown>>).map((s) => ({
|
||||
id: (s.uuid as string) ?? (s.id as string),
|
||||
name: (s.name as string) ?? "",
|
||||
address: s.address as string | undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchGroups(token: string, storeId: string): Promise<Array<{ id: string; name: string }>> {
|
||||
const data = await fetchJson(`${EVOTOR_API_BASE}/stores/${storeId}/product-groups`, token, [402]);
|
||||
if (!data) return [];
|
||||
const items = (typeof data === "object" && data !== null && "items" in data)
|
||||
? (data as { items: unknown[] }).items
|
||||
: (Array.isArray(data) ? data : []);
|
||||
return (items as Array<Record<string, unknown>>).map((g) => ({
|
||||
id: (g.uuid as string) ?? (g.id as string),
|
||||
name: (g.name as string) ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchProducts(token: string, storeId: string): Promise<Array<Record<string, unknown>>> {
|
||||
const data = await fetchJson(`${EVOTOR_API_BASE}/stores/${storeId}/products`, token, [402]);
|
||||
if (!data) return [];
|
||||
const items = (typeof data === "object" && data !== null && "items" in data)
|
||||
? (data as { items: unknown[] }).items
|
||||
: (Array.isArray(data) ? data : []);
|
||||
return (items as Array<Record<string, unknown>>).map((p) => ({
|
||||
id: (p.uuid as string) ?? (p.id as string),
|
||||
name: (p.name as string) ?? "",
|
||||
parent_id: (p.parentUuid as string) ?? (p.parent_id as string) ?? null,
|
||||
price: (p.price as number) ?? null,
|
||||
quantity: (p.quantity as number) ?? null,
|
||||
measure_name: (p.measureName as string) ?? (p.measure_name as string) ?? null,
|
||||
article_number: (p.code as string) ?? (p.article_number as string) ?? null,
|
||||
allow_to_sell: p.allowToSell !== undefined ? (p.allowToSell as boolean) : (p.allow_to_sell as boolean | null) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function refreshCatalogCache(userId: number, accessToken: string): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
await db.delete(cached_products).where(eq(cached_products.user_id, userId));
|
||||
await db.delete(cached_groups).where(eq(cached_groups.user_id, userId));
|
||||
await db.delete(cached_stores).where(eq(cached_stores.user_id, userId));
|
||||
|
||||
const stores = await fetchStores(accessToken);
|
||||
for (const store of stores) {
|
||||
await db.insert(cached_stores).values({
|
||||
user_id: userId,
|
||||
evotor_id: store.id,
|
||||
name: store.name,
|
||||
address: store.address ?? null,
|
||||
fetched_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
for (const store of stores) {
|
||||
const groups = await fetchGroups(accessToken, store.id);
|
||||
for (const group of groups) {
|
||||
await db.insert(cached_groups).values({
|
||||
user_id: userId,
|
||||
evotor_id: group.id,
|
||||
store_evotor_id: store.id,
|
||||
name: group.name,
|
||||
fetched_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
const products = await fetchProducts(accessToken, store.id);
|
||||
for (const product of products) {
|
||||
await db.insert(cached_products).values({
|
||||
user_id: userId,
|
||||
evotor_id: product.id as string,
|
||||
store_evotor_id: store.id,
|
||||
group_evotor_id: (product.parent_id as string | null) ?? null,
|
||||
name: product.name as string,
|
||||
price: product.price !== null ? String(product.price) : null,
|
||||
quantity: product.quantity !== null ? String(product.quantity) : null,
|
||||
measure_name: (product.measure_name as string | null) ?? null,
|
||||
article_number: (product.article_number as string | null) ?? null,
|
||||
allow_to_sell: (product.allow_to_sell as boolean | null) ?? null,
|
||||
fetched_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
138
web/src/lib/healthChecker.ts
Normal file
138
web/src/lib/healthChecker.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { db } from "../db/client.js";
|
||||
import { evotor_connections, vk_connections, cached_stores } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { refreshCatalogCache } from "./evotorApi.js";
|
||||
import { config } from "../config.js";
|
||||
|
||||
const EVOTOR_STORES_URL = "https://api.evotor.ru/stores";
|
||||
const EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token";
|
||||
const VK_GROUPS_GET_URL = "https://api.vk.com/method/groups.getById";
|
||||
const REFRESH_BEFORE_EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||
|
||||
async function checkEvotorConnection(token: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(EVOTOR_STORES_URL, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkVkConnection(token: string): Promise<boolean> {
|
||||
try {
|
||||
const params = new URLSearchParams({ access_token: token, v: "5.131" });
|
||||
const resp = await fetch(`${VK_GROUPS_GET_URL}?${params}`, { signal: AbortSignal.timeout(10000) });
|
||||
if (!resp.ok) return false;
|
||||
const data = await resp.json() as { error?: unknown };
|
||||
return !data.error;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshEvotorToken(conn: typeof evotor_connections.$inferSelect): Promise<{
|
||||
access_token: string; refresh_token?: string; expires_in?: number;
|
||||
} | null> {
|
||||
if (!conn.refresh_token) return null;
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: conn.refresh_token,
|
||||
});
|
||||
const resp = await fetch(EVOTOR_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json() as { access_token?: string; refresh_token?: string; expires_in?: number };
|
||||
return data.access_token ? data as { access_token: string; refresh_token?: string; expires_in?: number } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runHealthChecks(): Promise<void> {
|
||||
const now = new Date();
|
||||
console.log("[health] running health checks");
|
||||
|
||||
try {
|
||||
const evotorConns = await db.select().from(evotor_connections);
|
||||
for (const conn of evotorConns) {
|
||||
let token = conn.access_token;
|
||||
|
||||
// Proactive refresh if token expires soon
|
||||
const needsRefresh = conn.refresh_token &&
|
||||
conn.token_expires_at &&
|
||||
conn.token_expires_at.getTime() - now.getTime() < REFRESH_BEFORE_EXPIRY_MS;
|
||||
|
||||
if (needsRefresh) {
|
||||
const tokenData = await refreshEvotorToken(conn);
|
||||
if (tokenData) {
|
||||
token = tokenData.access_token;
|
||||
const expires = tokenData.expires_in ? new Date(now.getTime() + tokenData.expires_in * 1000) : null;
|
||||
await db.update(evotor_connections)
|
||||
.set({ access_token: token, refresh_token: tokenData.refresh_token ?? conn.refresh_token, token_expires_at: expires })
|
||||
.where(eq(evotor_connections.id, conn.id));
|
||||
}
|
||||
}
|
||||
|
||||
let isOnline = await checkEvotorConnection(token);
|
||||
|
||||
// If offline and not yet tried refresh, attempt it now
|
||||
if (!isOnline && conn.refresh_token && !needsRefresh) {
|
||||
const tokenData = await refreshEvotorToken(conn);
|
||||
if (tokenData) {
|
||||
token = tokenData.access_token;
|
||||
const expires = tokenData.expires_in ? new Date(now.getTime() + tokenData.expires_in * 1000) : null;
|
||||
await db.update(evotor_connections)
|
||||
.set({ access_token: token, refresh_token: tokenData.refresh_token ?? conn.refresh_token, token_expires_at: expires })
|
||||
.where(eq(evotor_connections.id, conn.id));
|
||||
isOnline = await checkEvotorConnection(token);
|
||||
}
|
||||
}
|
||||
|
||||
await db.update(evotor_connections)
|
||||
.set({ is_online: isOnline, last_checked_at: now })
|
||||
.where(eq(evotor_connections.id, conn.id));
|
||||
}
|
||||
|
||||
const vkConns = await db.select().from(vk_connections);
|
||||
for (const conn of vkConns) {
|
||||
const isOnline = await checkVkConnection(conn.access_token);
|
||||
await db.update(vk_connections)
|
||||
.set({ is_online: isOnline, last_checked_at: now })
|
||||
.where(eq(vk_connections.id, conn.id));
|
||||
}
|
||||
|
||||
// Refresh catalog cache for online Evotor connections
|
||||
let refreshed = 0;
|
||||
for (const conn of evotorConns) {
|
||||
if (!conn.is_online || !conn.user_id) continue;
|
||||
const [cached] = await db.select().from(cached_stores).where(eq(cached_stores.user_id, conn.user_id)).limit(1);
|
||||
const ageSeconds = cached ? (now.getTime() - cached.fetched_at.getTime()) / 1000 : Infinity;
|
||||
if (!cached || ageSeconds >= config.CATALOG_REFRESH_INTERVAL_SECONDS) {
|
||||
try {
|
||||
await refreshCatalogCache(conn.user_id, conn.access_token);
|
||||
refreshed++;
|
||||
} catch (err) {
|
||||
console.error(`[health] catalog refresh failed for user_id=${conn.user_id}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[health] done: ${evotorConns.length} evotor, ${vkConns.length} vk, ${refreshed} catalogs refreshed`);
|
||||
} catch (err) {
|
||||
console.error("[health] error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function startHealthCheckLoop(intervalMs: number): NodeJS.Timeout {
|
||||
return setInterval(() => {
|
||||
runHealthChecks().catch((err) => console.error("[health] uncaught error:", err));
|
||||
}, intervalMs);
|
||||
}
|
||||
39
web/src/lib/render.ts
Normal file
39
web/src/lib/render.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import nunjucks from "nunjucks";
|
||||
import path from "path";
|
||||
import { config } from "../config.js";
|
||||
|
||||
// In dev (tsx): templates are in src/templates/
|
||||
// In prod (node dist/): Dockerfile copies src/templates/ to dist/templates/
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const templatesDir = isDev
|
||||
? path.join(process.cwd(), "src", "templates")
|
||||
: path.join(process.cwd(), "dist", "templates");
|
||||
|
||||
const env = nunjucks.configure(templatesDir, {
|
||||
autoescape: true,
|
||||
noCache: process.env.NODE_ENV !== "production",
|
||||
});
|
||||
|
||||
env.addGlobal("jivosite_widget_id", config.JIVOSITE_WIDGET_ID);
|
||||
|
||||
// Format a JS Date to Russian date string
|
||||
env.addFilter("datefmt", (d: Date | null | undefined, fmt?: string) => {
|
||||
if (!d) return "";
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const yyyy = d.getFullYear();
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const min = String(d.getMinutes()).padStart(2, "0");
|
||||
if (fmt === "%Y%m%d") return `${yyyy}${mm}${dd}`;
|
||||
return `${dd}.${mm}.${yyyy} ${hh}:${min}`;
|
||||
});
|
||||
|
||||
// Format decimal price to 2 decimal places
|
||||
env.addFilter("price", (v: number | string | null | undefined) => {
|
||||
if (v == null) return "";
|
||||
return Number(v).toFixed(2);
|
||||
});
|
||||
|
||||
export function render(template: string, ctx: Record<string, unknown> = {}): string {
|
||||
return env.render(template, ctx);
|
||||
}
|
||||
361
web/src/lib/syncEngine.ts
Normal file
361
web/src/lib/syncEngine.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { db } from "../db/client.js";
|
||||
import { evotor_connections, vk_connections, sync_configs, sync_filters, cached_products } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { config } from "../config.js";
|
||||
import type { SyncFilter } from "../db/schema.js";
|
||||
|
||||
const VK_API_HOST = "https://api.vk.ru/method";
|
||||
const VK_API_VERSION = "5.199";
|
||||
const EVOTOR_API_BASE = "https://api.evotor.ru";
|
||||
const VK_CATEGORY_ID = 40932;
|
||||
const VK_STOCK_AMOUNT = 1000;
|
||||
const WEIGHT_PRICE_MULTIPLIER = 10;
|
||||
const WEIGHT_MEASURES = new Set(["г", "г.", "грамм", "граммов", "гр", "гр."]);
|
||||
|
||||
function isWeightMeasure(measure: string | null | undefined): boolean {
|
||||
return !!measure && WEIGHT_MEASURES.has(measure.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name.trim().replace(/;/g, ",");
|
||||
}
|
||||
|
||||
function calcPrice(priceKopecks: number | null, measure: string | null): [number, string] {
|
||||
const base = Math.round(Number(priceKopecks) || 0);
|
||||
if (isWeightMeasure(measure)) {
|
||||
return [base * WEIGHT_PRICE_MULTIPLIER, `${WEIGHT_PRICE_MULTIPLIER}${measure}`];
|
||||
}
|
||||
return [base, measure ?? ""];
|
||||
}
|
||||
|
||||
function buildDescription(name: string, priceInfo: string, extraDesc: string | null): string {
|
||||
let desc = `${name} (цена за ${priceInfo}.)\n\n`;
|
||||
if (extraDesc) desc += extraDesc;
|
||||
return desc.trim();
|
||||
}
|
||||
|
||||
function getIncludedStoreIds(filters: SyncFilter[]): string[] {
|
||||
return filters.filter((f) => f.entity_type === "store" && f.filter_mode === "include").map((f) => f.entity_id);
|
||||
}
|
||||
|
||||
function isGroupIncluded(groupId: string | null, filters: SyncFilter[]): boolean {
|
||||
const groupFilters = Object.fromEntries(
|
||||
filters.filter((f) => f.entity_type === "group").map((f) => [f.entity_id, f.filter_mode])
|
||||
);
|
||||
if (Object.keys(groupFilters).length === 0) return true;
|
||||
const mode = groupId ? groupFilters[groupId] : undefined;
|
||||
if (mode === "exclude") return false;
|
||||
if (mode === "include") return true;
|
||||
return !Object.values(groupFilters).some((v) => v === "include");
|
||||
}
|
||||
|
||||
function isProductIncluded(productId: string, filters: SyncFilter[]): boolean {
|
||||
const productFilters = Object.fromEntries(
|
||||
filters.filter((f) => f.entity_type === "product").map((f) => [f.entity_id, f.filter_mode])
|
||||
);
|
||||
if (Object.keys(productFilters).length === 0) return true;
|
||||
const mode = productFilters[productId];
|
||||
if (mode === "exclude") return false;
|
||||
if (mode === "include") return true;
|
||||
return !Object.values(productFilters).some((v) => v === "include");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Evotor API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function evoFetchProducts(token: string, storeId: string): Promise<Array<Record<string, unknown>>> {
|
||||
const resp = await fetch(`${EVOTOR_API_BASE}/stores/${storeId}/products`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if ([402, 404].includes(resp.status)) return [];
|
||||
if (!resp.ok) throw new Error(`Evotor products ${resp.status}`);
|
||||
const data = await resp.json() as { items?: unknown[] } | unknown[];
|
||||
return (Array.isArray(data) ? data : (data as { items?: unknown[] }).items ?? []) as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
async function evoFetchGroups(token: string, storeId: string): Promise<Array<Record<string, unknown>>> {
|
||||
const resp = await fetch(`${EVOTOR_API_BASE}/stores/${storeId}/product-groups`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if ([402, 404].includes(resp.status)) return [];
|
||||
if (!resp.ok) throw new Error(`Evotor groups ${resp.status}`);
|
||||
const data = await resp.json() as { items?: unknown[] } | unknown[];
|
||||
return (Array.isArray(data) ? data : (data as { items?: unknown[] }).items ?? []) as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VK API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function vkParams(token: string, extra: Record<string, unknown>): URLSearchParams {
|
||||
const p: Record<string, string> = { access_token: token, v: VK_API_VERSION };
|
||||
for (const [k, v] of Object.entries(extra)) p[k] = String(v);
|
||||
return new URLSearchParams(p);
|
||||
}
|
||||
|
||||
async function vkGet(token: string, method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
const resp = await fetch(`${VK_API_HOST}/${method}?${vkParams(token, params)}`, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`VK ${method} ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function vkPost(token: string, method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
const resp = await fetch(`${VK_API_HOST}/${method}`, {
|
||||
method: "POST",
|
||||
body: vkParams(token, params),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`VK ${method} ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function vkGetAlbums(token: string, ownerId: string): Promise<Array<Record<string, unknown>>> {
|
||||
const data = await vkGet(token, "market.getAlbums", { owner_id: ownerId, count: 200 }) as { response?: { items?: unknown[] } };
|
||||
return (data.response?.items ?? []) as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
async function vkCreateAlbum(token: string, ownerId: string, title: string): Promise<number | null> {
|
||||
const data = await vkPost(token, "market.addAlbum", { owner_id: ownerId, title }) as { error?: unknown; response?: { market_album_id?: number } };
|
||||
if (data.error) { console.error("[sync] VK create album error:", data.error); return null; }
|
||||
return data.response?.market_album_id ?? null;
|
||||
}
|
||||
|
||||
async function vkGetProducts(token: string, ownerId: string): Promise<Array<Record<string, unknown>>> {
|
||||
const items: Array<Record<string, unknown>> = [];
|
||||
let offset = 0;
|
||||
const count = 200;
|
||||
while (true) {
|
||||
const data = await vkGet(token, "market.get", { owner_id: ownerId, extended: 1, with_disabled: 1, count, offset }) as { response?: { items?: unknown[] } };
|
||||
const batch = (data.response?.items ?? []) as Array<Record<string, unknown>>;
|
||||
items.push(...batch);
|
||||
if (batch.length < count) break;
|
||||
offset += count;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function vkUploadPhoto(token: string, groupId: string, photoPath: string): Promise<string | null> {
|
||||
try {
|
||||
const serverData = await vkGet(token, "market.getProductPhotoUploadServer", { group_id: groupId }) as { error?: unknown; response?: { upload_url?: string } };
|
||||
if (serverData.error) return null;
|
||||
const uploadUrl = serverData.response?.upload_url;
|
||||
if (!uploadUrl) return null;
|
||||
|
||||
const fs = await import("fs");
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([fs.readFileSync(photoPath)]), "photo.jpg");
|
||||
|
||||
const uploadResp = await fetch(uploadUrl, { method: "POST", body: form as BodyInit, signal: AbortSignal.timeout(60000) });
|
||||
if (!uploadResp.ok) return null;
|
||||
const uploadText = await uploadResp.text();
|
||||
|
||||
const saveData = await vkPost(token, "market.saveProductPhoto", { upload_response: uploadText }) as { error?: unknown; response?: { photo_id?: string } };
|
||||
if (saveData.error) return null;
|
||||
return saveData.response?.photo_id ?? null;
|
||||
} catch (err) {
|
||||
console.error("[sync] photo upload error:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function vkCreateProduct(
|
||||
token: string, ownerId: string, name: string, description: string,
|
||||
price: number, stockAmount: number, photoId: string, albumId: number | null,
|
||||
): Promise<number | null> {
|
||||
const data = await vkPost(token, "market.add", {
|
||||
owner_id: ownerId, name, description, category_id: VK_CATEGORY_ID,
|
||||
price, main_photo_id: photoId, stock_amount: stockAmount,
|
||||
}) as { error?: unknown; response?: { market_item_id?: number } };
|
||||
if (data.error) { console.error("[sync] VK create product error:", data.error); return null; }
|
||||
const productId = data.response?.market_item_id ?? null;
|
||||
if (productId && albumId) {
|
||||
await vkGet(token, "market.addToAlbum", { owner_id: ownerId, item_ids: productId, album_ids: albumId }).catch(() => {});
|
||||
}
|
||||
return productId;
|
||||
}
|
||||
|
||||
async function vkEditProduct(
|
||||
token: string, ownerId: string, itemId: number, name: string,
|
||||
description: string, price: number, stockAmount: number,
|
||||
): Promise<void> {
|
||||
const data = await vkPost(token, "market.edit", {
|
||||
owner_id: ownerId, item_id: itemId, name, description, category_id: VK_CATEGORY_ID, price, stock_amount: stockAmount,
|
||||
}) as { error?: unknown };
|
||||
if (data.error) console.error("[sync] VK edit product error:", data.error);
|
||||
}
|
||||
|
||||
async function vkDeleteProduct(token: string, ownerId: string, itemId: number): Promise<void> {
|
||||
await vkPost(token, "market.delete", { owner_id: ownerId, item_id: itemId }).catch(() => {});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main sync logic per user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function stampSynced(userId: number, evoId: string, now: Date): Promise<void> {
|
||||
await db.update(cached_products)
|
||||
.set({ synced_at: now })
|
||||
.where(and(eq(cached_products.user_id, userId), eq(cached_products.evotor_id, evoId)));
|
||||
}
|
||||
|
||||
async function syncUser(
|
||||
userId: number,
|
||||
evoToken: string,
|
||||
vkToken: string,
|
||||
vkGroupId: string,
|
||||
filters: SyncFilter[],
|
||||
photoPath: string,
|
||||
): Promise<void> {
|
||||
const ownerId = `-${vkGroupId}`;
|
||||
const now = new Date();
|
||||
console.log(`[sync] start user_id=${userId} vk_group=${vkGroupId}`);
|
||||
|
||||
const storeIds = getIncludedStoreIds(filters);
|
||||
if (!storeIds.length) {
|
||||
console.log(`[sync] skip user_id=${userId} — no stores in filters`);
|
||||
return;
|
||||
}
|
||||
|
||||
const evoProducts: Array<Record<string, unknown>> = [];
|
||||
const groupsById: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const storeId of storeIds) {
|
||||
const rawGroups = await evoFetchGroups(evoToken, storeId);
|
||||
for (const g of rawGroups) {
|
||||
const gid = (g.uuid ?? g.id) as string;
|
||||
if (gid) groupsById[gid] = g;
|
||||
}
|
||||
const rawProducts = await evoFetchProducts(evoToken, storeId);
|
||||
for (const p of rawProducts) {
|
||||
const pid = (p.uuid ?? p.id) as string;
|
||||
const gid = (p.parentUuid ?? p.parent_id) as string | null;
|
||||
if (!isGroupIncluded(gid, filters)) continue;
|
||||
if (!isProductIncluded(pid, filters)) continue;
|
||||
evoProducts.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
const evoByName: Record<string, Record<string, unknown>> = {};
|
||||
for (const p of evoProducts) {
|
||||
const norm = normalizeName(((p.name as string) ?? "").trim());
|
||||
evoByName[norm] = p;
|
||||
}
|
||||
|
||||
const vkProducts = await vkGetProducts(vkToken, ownerId);
|
||||
const vkAlbums = await vkGetAlbums(vkToken, ownerId);
|
||||
const vkAlbumByTitle: Record<string, Record<string, unknown>> = {};
|
||||
for (const a of vkAlbums) vkAlbumByTitle[a.title as string] = a;
|
||||
|
||||
// Ensure albums exist for included groups
|
||||
for (const [gid, group] of Object.entries(groupsById)) {
|
||||
if (!isGroupIncluded(gid, filters)) continue;
|
||||
const title = (group.name as string) ?? "";
|
||||
if (title && !vkAlbumByTitle[title]) {
|
||||
const newId = await vkCreateAlbum(vkToken, ownerId, title);
|
||||
if (newId) {
|
||||
vkAlbumByTitle[title] = { id: newId, title };
|
||||
console.log(`[sync] created VK album '${title}' user_id=${userId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const vkByName: Record<string, Array<Record<string, unknown>>> = {};
|
||||
for (const item of vkProducts) {
|
||||
const norm = normalizeName((item.title as string) ?? "");
|
||||
(vkByName[norm] ??= []).push(item);
|
||||
}
|
||||
|
||||
// Update / create
|
||||
for (const [normName, evoP] of Object.entries(evoByName)) {
|
||||
const evoId = (evoP.uuid ?? evoP.id) as string;
|
||||
const rawName = ((evoP.name as string) ?? "").trim();
|
||||
const nameForVk = normalizeName(rawName);
|
||||
const measure = (evoP.measureName ?? evoP.measure_name) as string | null;
|
||||
const rawPrice = (evoP.price as number) ?? 0;
|
||||
const [price, priceInfo] = calcPrice(rawPrice, measure);
|
||||
const allowToSell = (evoP.allowToSell !== undefined ? evoP.allowToSell : evoP.allow_to_sell) as boolean | null;
|
||||
const stockAmount = allowToSell ? VK_STOCK_AMOUNT : 0;
|
||||
const extraDesc = (evoP.description as string) ?? "";
|
||||
const description = buildDescription(rawName, priceInfo, extraDesc);
|
||||
|
||||
const gid = (evoP.parentUuid ?? evoP.parent_id) as string | null;
|
||||
const groupName = gid ? (groupsById[gid]?.name as string) ?? null : null;
|
||||
const album = groupName ? vkAlbumByTitle[groupName] : null;
|
||||
const albumId = album ? (album.id as number) : null;
|
||||
|
||||
if (vkByName[normName]) {
|
||||
const vkItem = vkByName[normName][0];
|
||||
const vkId = vkItem.id as number;
|
||||
const origPrice = parseInt(String((vkItem.price as Record<string, unknown>)?.amount ?? 0));
|
||||
const origDesc = ((vkItem.description as string) ?? "").trim();
|
||||
const origStock = (vkItem.stock_amount as number) ?? 0;
|
||||
|
||||
if (price !== origPrice || description !== origDesc || stockAmount !== origStock) {
|
||||
console.log(`[sync] updating '${nameForVk}' user_id=${userId}`);
|
||||
await vkEditProduct(vkToken, ownerId, vkId, nameForVk, description, price, stockAmount);
|
||||
}
|
||||
await stampSynced(userId, evoId, now);
|
||||
} else {
|
||||
if (!allowToSell) continue;
|
||||
const photoId = await vkUploadPhoto(vkToken, vkGroupId, photoPath);
|
||||
if (!photoId) { console.error(`[sync] skip '${nameForVk}' — photo upload failed`); continue; }
|
||||
console.log(`[sync] creating '${nameForVk}' user_id=${userId}`);
|
||||
const created = await vkCreateProduct(vkToken, ownerId, nameForVk, description, price, stockAmount, photoId, albumId);
|
||||
if (created) await stampSynced(userId, evoId, now);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete VK products not in Evotor
|
||||
for (const [normName, vkItems] of Object.entries(vkByName)) {
|
||||
if (evoByName[normName]) {
|
||||
for (const dup of vkItems.slice(1)) {
|
||||
console.log(`[sync] deleting duplicate '${normName}' id=${dup.id} user_id=${userId}`);
|
||||
await vkDeleteProduct(vkToken, ownerId, dup.id as number);
|
||||
}
|
||||
} else {
|
||||
for (const item of vkItems) {
|
||||
console.log(`[sync] deleting removed '${normName}' id=${item.id} user_id=${userId}`);
|
||||
await vkDeleteProduct(vkToken, ownerId, item.id as number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[sync] complete user_id=${userId}`);
|
||||
}
|
||||
|
||||
export async function runSync(): Promise<void> {
|
||||
try {
|
||||
const configs = await db.select().from(sync_configs)
|
||||
.where(and(eq(sync_configs.is_enabled, true)));
|
||||
|
||||
for (const cfg of configs) {
|
||||
if (!cfg.confirmed_at) continue;
|
||||
|
||||
const [evo] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, cfg.user_id)).limit(1);
|
||||
const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, cfg.user_id)).limit(1);
|
||||
|
||||
if (!evo?.access_token || !vk?.access_token || !vk.vk_user_id) continue;
|
||||
|
||||
const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, cfg.id));
|
||||
|
||||
try {
|
||||
await syncUser(cfg.user_id, evo.access_token, vk.access_token, vk.vk_user_id, filters, config.VK_DEFAULT_PHOTO_PATH);
|
||||
} catch (err) {
|
||||
console.error(`[sync] failed for user_id=${cfg.user_id}:`, err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[sync] runner error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function startSyncLoop(intervalMs: number): NodeJS.Timeout {
|
||||
return setInterval(() => {
|
||||
runSync().catch((err) => console.error("[sync] uncaught error:", err));
|
||||
}, intervalMs);
|
||||
}
|
||||
40
web/src/lib/validate.ts
Normal file
40
web/src/lib/validate.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
const PHONE_RE = /^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$/;
|
||||
const EMAIL_RE = /^[^@]+@[^@]+\.[^@]+$/;
|
||||
|
||||
export function validateRegistration(data: Record<string, string>): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!data.first_name?.trim()) errors.push("Введите имя");
|
||||
if (!data.last_name?.trim()) errors.push("Введите фамилию");
|
||||
const email = data.email?.trim() ?? "";
|
||||
if (!email || !EMAIL_RE.test(email)) errors.push("Введите корректный email");
|
||||
const phone = data.phone?.trim() ?? "";
|
||||
if (!phone || !PHONE_RE.test(phone)) errors.push("Введите телефон в формате +7 (XXX) XXX-XX-XX");
|
||||
const password = data.password ?? "";
|
||||
if (password.length < 8) errors.push("Пароль должен быть не менее 8 символов");
|
||||
if (password !== data.password_confirm) errors.push("Пароли не совпадают");
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateLogin(data: Record<string, string>): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!data.email?.trim()) errors.push("Введите email");
|
||||
if (!data.password) errors.push("Введите пароль");
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateResetPassword(data: Record<string, string>): string[] {
|
||||
const errors: string[] = [];
|
||||
const password = data.password ?? "";
|
||||
if (password.length < 8) errors.push("Пароль должен быть не менее 8 символов");
|
||||
if (password !== data.password_confirm) errors.push("Пароли не совпадают");
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateProfile(data: Record<string, string>): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!data.first_name?.trim()) errors.push("Введите имя");
|
||||
if (!data.last_name?.trim()) errors.push("Введите фамилию");
|
||||
const phone = data.phone?.trim() ?? "";
|
||||
if (!phone || !PHONE_RE.test(phone)) errors.push("Введите телефон в формате +7 (XXX) XXX-XX-XX");
|
||||
return errors;
|
||||
}
|
||||
124
web/src/routes/auth.ts
Normal file
124
web/src/routes/auth.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Hono } from "hono";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { hashPassword, verifyPassword, getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { validateRegistration, validateLogin } from "../lib/validate.js";
|
||||
import { render } from "../lib/render.js";
|
||||
import { config } from "../config.js";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export const authRouter = new Hono<AppEnv>();
|
||||
|
||||
authRouter.get("/register", async (c) => {
|
||||
const userId = getSessionUserId(c);
|
||||
if (userId) return c.redirect("/profile", 303);
|
||||
return c.html(render("register.njk", { user: null }));
|
||||
});
|
||||
|
||||
authRouter.post("/register", async (c) => {
|
||||
const form = await c.req.formData();
|
||||
const data: Record<string, string> = {};
|
||||
form.forEach((v, k) => { data[k] = String(v); });
|
||||
|
||||
const errors = validateRegistration(data);
|
||||
|
||||
if (!errors.length) {
|
||||
const existing = await db.select().from(users).where(
|
||||
or(eq(users.email, data.email.trim()), eq(users.phone, data.phone.trim()))
|
||||
).limit(1);
|
||||
if (existing.length) {
|
||||
if (existing[0].email === data.email.trim()) {
|
||||
errors.push("Пользователь с таким email уже существует");
|
||||
} else {
|
||||
errors.push("Пользователь с таким телефоном уже существует");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
return c.html(render("register.njk", { user: null, errors, form: data }));
|
||||
}
|
||||
|
||||
const token = randomUUID().replace(/-/g, "");
|
||||
await db.insert(users).values({
|
||||
first_name: data.first_name.trim(),
|
||||
last_name: data.last_name.trim(),
|
||||
email: data.email.trim(),
|
||||
phone: data.phone.trim(),
|
||||
password_hash: await hashPassword(data.password),
|
||||
email_confirm_token: token,
|
||||
});
|
||||
|
||||
const confirmUrl = `${config.BASE_URL}/confirm-email?token=${token}`;
|
||||
console.log("=".repeat(40));
|
||||
console.log("ПОДТВЕРЖДЕНИЕ EMAIL");
|
||||
console.log(`Пользователь: ${data.email.trim()}`);
|
||||
console.log(`Ссылка: ${confirmUrl}`);
|
||||
console.log("=".repeat(40));
|
||||
|
||||
return c.html(render("confirm_email.njk", { user: null }));
|
||||
});
|
||||
|
||||
authRouter.get("/confirm-email", async (c) => {
|
||||
const token = c.req.query("token");
|
||||
if (!token) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
|
||||
}));
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.email_confirm_token, token)).limit(1);
|
||||
if (!user) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
|
||||
}));
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({ is_email_confirmed: true, email_confirm_token: null })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return c.html(render("email_confirmed.njk", { user: null }));
|
||||
});
|
||||
|
||||
authRouter.get("/login", async (c) => {
|
||||
const userId = getSessionUserId(c);
|
||||
if (userId) return c.redirect("/profile", 303);
|
||||
return c.html(render("login.njk", { user: null }));
|
||||
});
|
||||
|
||||
authRouter.post("/login", async (c) => {
|
||||
const form = await c.req.formData();
|
||||
const data: Record<string, string> = {};
|
||||
form.forEach((v, k) => { data[k] = String(v); });
|
||||
|
||||
const errors = validateLogin(data);
|
||||
if (errors.length) {
|
||||
return c.html(render("login.njk", { user: null, errors, form: data }));
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.email, data.email.trim())).limit(1);
|
||||
if (!user || !(await verifyPassword(data.password, user.password_hash))) {
|
||||
return c.html(render("login.njk", {
|
||||
user: null, errors: ["Неверный email или пароль"], form: data,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!user.is_email_confirmed) {
|
||||
return c.html(render("login.njk", {
|
||||
user: null, errors: ["Пожалуйста, подтвердите ваш email"], form: data,
|
||||
}));
|
||||
}
|
||||
|
||||
const session = c.get("session") as { set: (k: string, v: unknown) => void };
|
||||
session.set("user_id", user.id);
|
||||
return c.redirect("/profile", 303);
|
||||
});
|
||||
|
||||
authRouter.get("/logout", (c) => {
|
||||
const session = c.get("session") as { deleteSession: () => void };
|
||||
session.deleteSession();
|
||||
return c.redirect("/login", 303);
|
||||
});
|
||||
260
web/src/routes/catalog.ts
Normal file
260
web/src/routes/catalog.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, evotor_connections, sync_configs, sync_filters, cached_stores, cached_groups, cached_products } from "../db/schema.js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { render } from "../lib/render.js";
|
||||
import { refreshCatalogCache } from "../lib/evotorApi.js";
|
||||
|
||||
export const catalogRouter = new Hono<AppEnv>();
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
async function getOrCreateSyncConfig(userId: number) {
|
||||
const [existing] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
|
||||
if (existing) return existing;
|
||||
await db.insert(sync_configs).values({ user_id: userId, is_enabled: false });
|
||||
const [created] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
|
||||
return created!;
|
||||
}
|
||||
|
||||
async function getFilterMap(configId: number): Promise<Record<string, string>> {
|
||||
const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, configId));
|
||||
return Object.fromEntries(filters.map((f) => [f.entity_id, f.filter_mode]));
|
||||
}
|
||||
|
||||
catalogRouter.get("/catalog", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
if (!evotor) {
|
||||
return c.html(render("catalog_stores.njk", { user, evotor: null, stores: [], filter_map: {}, fetched_at: null }));
|
||||
}
|
||||
|
||||
let stores = await db.select().from(cached_stores)
|
||||
.where(eq(cached_stores.user_id, user.id))
|
||||
.orderBy(cached_stores.name);
|
||||
|
||||
if (!stores.length) {
|
||||
await refreshCatalogCache(user.id, evotor.access_token);
|
||||
stores = await db.select().from(cached_stores)
|
||||
.where(eq(cached_stores.user_id, user.id))
|
||||
.orderBy(cached_stores.name);
|
||||
}
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
const filter_map = await getFilterMap(config.id);
|
||||
const fetched_at = stores[0]?.fetched_at ?? null;
|
||||
|
||||
return c.html(render("catalog_stores.njk", { user, evotor, stores, filter_map, fetched_at }));
|
||||
});
|
||||
|
||||
catalogRouter.get("/catalog/groups", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const store_id = c.req.query("store_id") ?? "";
|
||||
const [store] = await db.select().from(cached_stores)
|
||||
.where(and(eq(cached_stores.user_id, user.id), eq(cached_stores.evotor_id, store_id))).limit(1);
|
||||
if (!store) return c.redirect("/catalog", 303);
|
||||
|
||||
const groups = await db.select().from(cached_groups)
|
||||
.where(and(eq(cached_groups.user_id, user.id), eq(cached_groups.store_evotor_id, store_id)))
|
||||
.orderBy(cached_groups.name);
|
||||
|
||||
const product_counts: Record<string, number> = {};
|
||||
for (const g of groups) {
|
||||
const [row] = await db.select({ count: sql<number>`count(*)` }).from(cached_products)
|
||||
.where(and(eq(cached_products.user_id, user.id), eq(cached_products.group_evotor_id, g.evotor_id)));
|
||||
product_counts[g.evotor_id] = Number(row?.count ?? 0);
|
||||
}
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
const filter_map = await getFilterMap(config.id);
|
||||
|
||||
return c.html(render("catalog_groups.njk", { user, store, groups, product_counts, filter_map }));
|
||||
});
|
||||
|
||||
catalogRouter.get("/catalog/products", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const store_id = c.req.query("store_id") ?? "";
|
||||
const group_id = c.req.query("group_id") ?? null;
|
||||
|
||||
const [store] = await db.select().from(cached_stores)
|
||||
.where(and(eq(cached_stores.user_id, user.id), eq(cached_stores.evotor_id, store_id))).limit(1);
|
||||
if (!store) return c.redirect("/catalog", 303);
|
||||
|
||||
let group = null;
|
||||
let products;
|
||||
|
||||
if (group_id) {
|
||||
[group] = await db.select().from(cached_groups)
|
||||
.where(and(eq(cached_groups.user_id, user.id), eq(cached_groups.evotor_id, group_id))).limit(1);
|
||||
products = await db.select().from(cached_products)
|
||||
.where(and(
|
||||
eq(cached_products.user_id, user.id),
|
||||
eq(cached_products.store_evotor_id, store_id),
|
||||
eq(cached_products.group_evotor_id, group_id),
|
||||
))
|
||||
.orderBy(cached_products.name);
|
||||
} else {
|
||||
products = await db.select().from(cached_products)
|
||||
.where(and(eq(cached_products.user_id, user.id), eq(cached_products.store_evotor_id, store_id)))
|
||||
.orderBy(cached_products.name);
|
||||
}
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
const filter_map = await getFilterMap(config.id);
|
||||
|
||||
return c.html(render("catalog_products.njk", { user, store, group: group ?? null, products, filter_map }));
|
||||
});
|
||||
|
||||
catalogRouter.post("/catalog/filter", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const entity_type = String(form.get("entity_type") ?? "");
|
||||
const entity_id = String(form.get("entity_id") ?? "");
|
||||
const entity_name = String(form.get("entity_name") ?? "") || null;
|
||||
const filter_mode = String(form.get("filter_mode") ?? "");
|
||||
const parent_entity_id = String(form.get("parent_entity_id") ?? "") || null;
|
||||
const redirect_to = String(form.get("redirect_to") ?? "/catalog");
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
|
||||
const [existing] = await db.select().from(sync_filters)
|
||||
.where(and(
|
||||
eq(sync_filters.sync_config_id, config.id),
|
||||
eq(sync_filters.entity_type, entity_type),
|
||||
eq(sync_filters.entity_id, entity_id),
|
||||
)).limit(1);
|
||||
|
||||
if (filter_mode === "none") {
|
||||
if (existing) await db.delete(sync_filters).where(eq(sync_filters.id, existing.id));
|
||||
} else if (existing) {
|
||||
await db.update(sync_filters)
|
||||
.set({ filter_mode, entity_name })
|
||||
.where(eq(sync_filters.id, existing.id));
|
||||
} else {
|
||||
await db.insert(sync_filters).values({
|
||||
sync_config_id: config.id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
entity_name,
|
||||
filter_mode,
|
||||
parent_entity_id,
|
||||
});
|
||||
}
|
||||
|
||||
return c.redirect(redirect_to, 303);
|
||||
});
|
||||
|
||||
catalogRouter.post("/catalog/refresh", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
if (evotor) await refreshCatalogCache(user.id, evotor.access_token);
|
||||
|
||||
return c.redirect("/catalog", 303);
|
||||
});
|
||||
|
||||
catalogRouter.get("/catalog/export", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const type = c.req.query("type") ?? "products";
|
||||
const store_id = c.req.query("store_id") ?? null;
|
||||
const group_id = c.req.query("group_id") ?? null;
|
||||
|
||||
const syncConfig = await getOrCreateSyncConfig(user.id);
|
||||
const fmap = await getFilterMap(syncConfig.id);
|
||||
|
||||
const filterLabel = (eid: string) => {
|
||||
const m = fmap[eid];
|
||||
if (m === "include") return "Включено";
|
||||
if (m === "exclude") return "Исключено";
|
||||
return "Нет правила";
|
||||
};
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
let filename: string;
|
||||
const rows: string[][] = [];
|
||||
|
||||
const BOM = "\uFEFF";
|
||||
|
||||
if (type === "stores") {
|
||||
filename = `stores_${today}.csv`;
|
||||
rows.push(["Название", "Адрес", "ID", "Фильтр"]);
|
||||
const stores = await db.select().from(cached_stores)
|
||||
.where(eq(cached_stores.user_id, user.id))
|
||||
.orderBy(cached_stores.name);
|
||||
for (const s of stores) {
|
||||
rows.push([s.name, s.address ?? "", s.evotor_id, filterLabel(s.evotor_id)]);
|
||||
}
|
||||
} else if (type === "groups") {
|
||||
filename = `groups_${today}.csv`;
|
||||
rows.push(["Магазин", "Название", "ID", "Фильтр"]);
|
||||
const storeMap: Record<string, string> = {};
|
||||
const allStores = await db.select().from(cached_stores).where(eq(cached_stores.user_id, user.id));
|
||||
for (const s of allStores) storeMap[s.evotor_id] = s.name;
|
||||
|
||||
const q = db.select().from(cached_groups).where(
|
||||
store_id
|
||||
? and(eq(cached_groups.user_id, user.id), eq(cached_groups.store_evotor_id, store_id))
|
||||
: eq(cached_groups.user_id, user.id)
|
||||
).orderBy(cached_groups.name);
|
||||
const groups = await q;
|
||||
for (const g of groups) {
|
||||
rows.push([storeMap[g.store_evotor_id] ?? "", g.name, g.evotor_id, filterLabel(g.evotor_id)]);
|
||||
}
|
||||
} else {
|
||||
filename = `products_${today}.csv`;
|
||||
rows.push(["Магазин", "Группа", "Название", "Артикул", "Цена", "Количество", "Ед. измерения", "В продаже", "ID", "Фильтр"]);
|
||||
const storeMap: Record<string, string> = {};
|
||||
const groupMap: Record<string, string> = {};
|
||||
const allStores = await db.select().from(cached_stores).where(eq(cached_stores.user_id, user.id));
|
||||
for (const s of allStores) storeMap[s.evotor_id] = s.name;
|
||||
const allGroups = await db.select().from(cached_groups).where(eq(cached_groups.user_id, user.id));
|
||||
for (const g of allGroups) groupMap[g.evotor_id] = g.name;
|
||||
|
||||
const conditions = [eq(cached_products.user_id, user.id)];
|
||||
if (store_id) conditions.push(eq(cached_products.store_evotor_id, store_id));
|
||||
if (group_id) conditions.push(eq(cached_products.group_evotor_id, group_id));
|
||||
const products = await db.select().from(cached_products).where(and(...conditions)).orderBy(cached_products.name);
|
||||
for (const p of products) {
|
||||
rows.push([
|
||||
storeMap[p.store_evotor_id] ?? "",
|
||||
p.group_evotor_id ? (groupMap[p.group_evotor_id] ?? "") : "",
|
||||
p.name,
|
||||
p.article_number ?? "",
|
||||
p.price ? String(p.price) : "",
|
||||
p.quantity ? String(p.quantity) : "",
|
||||
p.measure_name ?? "",
|
||||
p.allow_to_sell === true ? "Да" : p.allow_to_sell === false ? "Нет" : "",
|
||||
p.evotor_id,
|
||||
filterLabel(p.evotor_id),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const csv = BOM + rows.map((row) =>
|
||||
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")
|
||||
).join("\r\n");
|
||||
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
83
web/src/routes/connections.ts
Normal file
83
web/src/routes/connections.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, evotor_connections, vk_connections } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { render } from "../lib/render.js";
|
||||
|
||||
export const connectionsRouter = new Hono<AppEnv>();
|
||||
|
||||
const SERVICE_TYPES = [
|
||||
{
|
||||
type: "evotor",
|
||||
name: "Эвотор",
|
||||
icon: "bi-shop",
|
||||
description: "Подключите кассу Эвотор для синхронизации каталога товаров.",
|
||||
configure_url: "/evotor",
|
||||
connect_url: "/evotor",
|
||||
},
|
||||
{
|
||||
type: "vk",
|
||||
name: "ВКонтакте",
|
||||
icon: "bi-bag",
|
||||
description: "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.",
|
||||
configure_url: "/vk",
|
||||
connect_url: "/vk",
|
||||
},
|
||||
];
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
connectionsRouter.get("/connections", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
|
||||
|
||||
const connections = SERVICE_TYPES
|
||||
.map((svc) => {
|
||||
const conn = svc.type === "evotor" ? evotor : vk;
|
||||
if (!conn) return null;
|
||||
const details = svc.type === "evotor"
|
||||
? (evotor?.store_name ?? null)
|
||||
: (vk?.first_name ? `${vk.first_name} ${vk.last_name ?? ""}`.trim() : null);
|
||||
return { ...svc, is_online: conn.is_online, last_checked_at: conn.last_checked_at, details };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return c.html(render("connections.njk", { user, connections }));
|
||||
});
|
||||
|
||||
connectionsRouter.get("/connections/add", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
|
||||
|
||||
const available = SERVICE_TYPES.filter((svc) => {
|
||||
return svc.type === "evotor" ? !evotor : !vk;
|
||||
});
|
||||
|
||||
return c.html(render("connections_add.njk", { user, available }));
|
||||
});
|
||||
|
||||
connectionsRouter.post("/connections/delete", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const svcType = c.req.query("type");
|
||||
if (svcType === "evotor") {
|
||||
await db.delete(evotor_connections).where(eq(evotor_connections.user_id, user.id));
|
||||
} else if (svcType === "vk") {
|
||||
await db.delete(vk_connections).where(eq(vk_connections.user_id, user.id));
|
||||
}
|
||||
|
||||
return c.redirect("/connections", 303);
|
||||
});
|
||||
147
web/src/routes/evotor.ts
Normal file
147
web/src/routes/evotor.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, evotor_connections } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { render } from "../lib/render.js";
|
||||
import { config } from "../config.js";
|
||||
|
||||
export const evotorRouter = new Hono<AppEnv>();
|
||||
|
||||
const EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}";
|
||||
const EVOTOR_STORES_URL = "https://api.evotor.ru/stores";
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
async function fetchStoreInfo(token: string): Promise<{ store_id: string | null; store_name: string | null }> {
|
||||
try {
|
||||
const resp = await fetch(EVOTOR_STORES_URL, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json() as unknown;
|
||||
const stores = (typeof data === "object" && data !== null && "items" in data)
|
||||
? (data as { items: unknown[] }).items
|
||||
: (Array.isArray(data) ? data : []);
|
||||
if (Array.isArray(stores) && stores.length > 0) {
|
||||
const s = stores[0] as Record<string, unknown>;
|
||||
return {
|
||||
store_id: (s.uuid as string | null) ?? (s.id as string | null) ?? null,
|
||||
store_name: (s.name as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { store_id: null, store_name: null };
|
||||
} catch {
|
||||
return { store_id: null, store_name: null };
|
||||
}
|
||||
}
|
||||
|
||||
evotorRouter.get("/evotor", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [connection] = await db.select().from(evotor_connections)
|
||||
.where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
const error = c.req.query("error") ?? null;
|
||||
const app_url = config.EVOTOR_APP_ID
|
||||
? EVOTOR_APP_URL.replace("{app_id}", config.EVOTOR_APP_ID)
|
||||
: null;
|
||||
|
||||
return c.html(render("evotor.njk", { user, connection: connection ?? null, error, app_url }));
|
||||
});
|
||||
|
||||
// Webhook endpoint: Evotor POSTs {"userId": "...", "token": "..."}
|
||||
evotorRouter.post("/evotor/callback", async (c) => {
|
||||
if (config.EVOTOR_WEBHOOK_SECRET) {
|
||||
const authHeader = c.req.header("Authorization") ?? "";
|
||||
if (authHeader !== `Bearer ${config.EVOTOR_WEBHOOK_SECRET}`) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await c.req.json() as { userId?: string; token?: string };
|
||||
if (!payload.userId || !payload.token) {
|
||||
return c.json({ error: "Invalid payload" }, 400);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const { store_id, store_name } = await fetchStoreInfo(payload.token);
|
||||
|
||||
const [existing] = await db.select().from(evotor_connections)
|
||||
.where(eq(evotor_connections.evotor_user_id, payload.userId)).limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db.update(evotor_connections)
|
||||
.set({ access_token: payload.token, store_id, store_name, is_online: true, last_checked_at: now, updated_at: now })
|
||||
.where(eq(evotor_connections.id, existing.id));
|
||||
} else {
|
||||
await db.insert(evotor_connections).values({
|
||||
evotor_user_id: payload.userId,
|
||||
access_token: payload.token,
|
||||
store_id,
|
||||
store_name,
|
||||
is_online: true,
|
||||
last_checked_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
|
||||
evotorRouter.post("/evotor/token", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const token = String(form.get("token") ?? "").trim();
|
||||
if (!token) return c.redirect("/evotor?error=empty_token", 303);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Validate token by fetching stores
|
||||
let storeInfo: { store_id: string | null; store_name: string | null };
|
||||
try {
|
||||
const resp = await fetch(EVOTOR_STORES_URL, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (resp.status === 401) return c.redirect("/evotor?error=invalid_token", 303);
|
||||
storeInfo = await fetchStoreInfo(token);
|
||||
} catch {
|
||||
storeInfo = { store_id: null, store_name: null };
|
||||
}
|
||||
|
||||
const [existing] = await db.select().from(evotor_connections)
|
||||
.where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db.update(evotor_connections)
|
||||
.set({ access_token: token, store_id: storeInfo.store_id, store_name: storeInfo.store_name, is_online: true, last_checked_at: now, updated_at: now })
|
||||
.where(eq(evotor_connections.id, existing.id));
|
||||
} else {
|
||||
await db.insert(evotor_connections).values({
|
||||
user_id: user.id,
|
||||
access_token: token,
|
||||
store_id: storeInfo.store_id,
|
||||
store_name: storeInfo.store_name,
|
||||
is_online: true,
|
||||
last_checked_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
return c.redirect("/connections", 303);
|
||||
});
|
||||
|
||||
evotorRouter.post("/evotor/disconnect", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
await db.delete(evotor_connections).where(eq(evotor_connections.user_id, user.id));
|
||||
return c.redirect("/connections", 303);
|
||||
});
|
||||
116
web/src/routes/profile.ts
Normal file
116
web/src/routes/profile.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
import { hashPassword, verifyPassword, getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { validateProfile, validateResetPassword } from "../lib/validate.js";
|
||||
import { render } from "../lib/render.js";
|
||||
|
||||
export const profileRouter = new Hono<AppEnv>();
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
profileRouter.get("/profile", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
return c.html(render("profile_view.njk", { user }));
|
||||
});
|
||||
|
||||
profileRouter.get("/profile/edit", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
return c.html(render("profile_edit.njk", { user }));
|
||||
});
|
||||
|
||||
profileRouter.post("/profile/edit", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const data: Record<string, string> = {};
|
||||
form.forEach((v, k) => { data[k] = String(v); });
|
||||
|
||||
const errors = validateProfile(data);
|
||||
if (!errors.length) {
|
||||
const existing = await db.select().from(users).where(
|
||||
and(eq(users.phone, data.phone.trim()), ne(users.id, user.id))
|
||||
).limit(1);
|
||||
if (existing.length) errors.push("Пользователь с таким телефоном уже существует");
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
return c.html(render("profile_edit.njk", { user, errors, form: data }));
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({ first_name: data.first_name.trim(), last_name: data.last_name.trim(), phone: data.phone.trim() })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
const [updated] = await db.select().from(users).where(eq(users.id, user.id)).limit(1);
|
||||
return c.html(render("profile_edit.njk", { user: updated, success: "Профиль обновлен" }));
|
||||
});
|
||||
|
||||
profileRouter.get("/profile/change-password", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
return c.html(render("profile_change_password.njk", { user }));
|
||||
});
|
||||
|
||||
profileRouter.post("/profile/change-password", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const data: Record<string, string> = {};
|
||||
form.forEach((v, k) => { data[k] = String(v); });
|
||||
|
||||
const errors: string[] = [];
|
||||
const currentPassword = data.current_password ?? "";
|
||||
if (!currentPassword) {
|
||||
errors.push("Введите текущий пароль");
|
||||
} else if (!(await verifyPassword(currentPassword, user.password_hash))) {
|
||||
errors.push("Неверный текущий пароль");
|
||||
}
|
||||
errors.push(...validateResetPassword(data));
|
||||
|
||||
if (errors.length) {
|
||||
return c.html(render("profile_change_password.njk", { user, errors }));
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({ password_hash: await hashPassword(data.password) })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return c.html(render("profile_change_password.njk", { user, success: "Пароль изменен" }));
|
||||
});
|
||||
|
||||
profileRouter.get("/profile/delete", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
return c.html(render("profile_delete.njk", { user }));
|
||||
});
|
||||
|
||||
profileRouter.post("/profile/delete", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const password = String(form.get("password") ?? "");
|
||||
|
||||
if (!password) {
|
||||
return c.html(render("profile_delete.njk", { user, errors: ["Введите пароль для подтверждения"] }));
|
||||
}
|
||||
if (!(await verifyPassword(password, user.password_hash))) {
|
||||
return c.html(render("profile_delete.njk", { user, errors: ["Неверный пароль"] }));
|
||||
}
|
||||
|
||||
await db.delete(users).where(eq(users.id, user.id));
|
||||
const session = c.get("session") as { deleteSession: () => void };
|
||||
session.deleteSession();
|
||||
return c.redirect("/", 303);
|
||||
});
|
||||
100
web/src/routes/reset.ts
Normal file
100
web/src/routes/reset.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hashPassword, type AppEnv } from "../lib/auth.js";
|
||||
import { validateResetPassword } from "../lib/validate.js";
|
||||
import { render } from "../lib/render.js";
|
||||
import { config } from "../config.js";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export const resetRouter = new Hono<AppEnv>();
|
||||
|
||||
resetRouter.get("/forgot-password", (c) => {
|
||||
return c.html(render("forgot_password.njk", { user: null }));
|
||||
});
|
||||
|
||||
resetRouter.post("/forgot-password", async (c) => {
|
||||
const form = await c.req.formData();
|
||||
const email = (String(form.get("email") ?? "")).trim();
|
||||
|
||||
if (email) {
|
||||
const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||
if (user) {
|
||||
const token = randomUUID().replace(/-/g, "");
|
||||
const expires = new Date(Date.now() + config.PASSWORD_RESET_EXPIRE_MINUTES * 60 * 1000);
|
||||
await db.update(users)
|
||||
.set({ password_reset_token: token, password_reset_expires: expires })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
const resetUrl = `${config.BASE_URL}/reset-password?token=${token}`;
|
||||
console.log("=".repeat(40));
|
||||
console.log("СБРОС ПАРОЛЯ");
|
||||
console.log(`Пользователь: ${user.email}`);
|
||||
console.log(`Ссылка: ${resetUrl}`);
|
||||
console.log(`Действительна: ${config.PASSWORD_RESET_EXPIRE_MINUTES} мин.`);
|
||||
console.log("=".repeat(40));
|
||||
}
|
||||
}
|
||||
|
||||
return c.html(render("message.njk", {
|
||||
user: null,
|
||||
title: "Сброс пароля",
|
||||
message: "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.",
|
||||
}));
|
||||
});
|
||||
|
||||
resetRouter.get("/reset-password", async (c) => {
|
||||
const token = c.req.query("token") ?? "";
|
||||
const [user] = await db.select().from(users).where(eq(users.password_reset_token, token)).limit(1);
|
||||
|
||||
if (!user || !user.password_reset_expires) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
|
||||
}));
|
||||
}
|
||||
if (new Date() > user.password_reset_expires) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Срок действия ссылки истек.",
|
||||
}));
|
||||
}
|
||||
|
||||
return c.html(render("reset_password.njk", { user: null, token }));
|
||||
});
|
||||
|
||||
resetRouter.post("/reset-password", async (c) => {
|
||||
const token = c.req.query("token") ?? "";
|
||||
const [user] = await db.select().from(users).where(eq(users.password_reset_token, token)).limit(1);
|
||||
|
||||
if (!user || !user.password_reset_expires) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Неверная или устаревшая ссылка.",
|
||||
}));
|
||||
}
|
||||
if (new Date() > user.password_reset_expires) {
|
||||
return c.html(render("message.njk", {
|
||||
user: null, title: "Ошибка", message: "Срок действия ссылки истек.",
|
||||
}));
|
||||
}
|
||||
|
||||
const form = await c.req.formData();
|
||||
const data: Record<string, string> = {};
|
||||
form.forEach((v, k) => { data[k] = String(v); });
|
||||
const errors = validateResetPassword(data);
|
||||
|
||||
if (errors.length) {
|
||||
return c.html(render("reset_password.njk", { user: null, token, errors }));
|
||||
}
|
||||
|
||||
await db.update(users)
|
||||
.set({ password_hash: await hashPassword(data.password), password_reset_token: null, password_reset_expires: null })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return c.html(render("message.njk", {
|
||||
user: null,
|
||||
title: "Пароль изменен",
|
||||
message: "Ваш пароль успешно изменен. Теперь вы можете войти.",
|
||||
link: "/login",
|
||||
link_text: "Войти",
|
||||
}));
|
||||
});
|
||||
88
web/src/routes/sync.ts
Normal file
88
web/src/routes/sync.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, evotor_connections, vk_connections, sync_configs, sync_filters } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { render } from "../lib/render.js";
|
||||
|
||||
export const syncRouter = new Hono<AppEnv>();
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
async function getOrCreateSyncConfig(userId: number) {
|
||||
const [existing] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
|
||||
if (existing) return existing;
|
||||
await db.insert(sync_configs).values({ user_id: userId, is_enabled: false });
|
||||
const [created] = await db.select().from(sync_configs).where(eq(sync_configs.user_id, userId)).limit(1);
|
||||
return created!;
|
||||
}
|
||||
|
||||
syncRouter.get("/sync", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [evotor] = await db.select().from(evotor_connections).where(eq(evotor_connections.user_id, user.id)).limit(1);
|
||||
const [vk] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, config.id));
|
||||
|
||||
const summary = {
|
||||
stores: filters.filter((f) => f.entity_type === "store").length,
|
||||
groups: filters.filter((f) => f.entity_type === "group").length,
|
||||
products: filters.filter((f) => f.entity_type === "product").length,
|
||||
total: filters.length,
|
||||
};
|
||||
|
||||
let status: string;
|
||||
if (config.confirmed_at && config.is_enabled) {
|
||||
status = "active";
|
||||
} else if (config.confirmed_at && !config.is_enabled) {
|
||||
status = "paused";
|
||||
} else if (summary.total > 0) {
|
||||
status = "pending";
|
||||
} else {
|
||||
status = "unconfigured";
|
||||
}
|
||||
|
||||
return c.html(render("sync.njk", {
|
||||
user,
|
||||
evotor: evotor ?? null,
|
||||
vk: vk ?? null,
|
||||
config,
|
||||
summary,
|
||||
status,
|
||||
}));
|
||||
});
|
||||
|
||||
syncRouter.post("/sync/toggle", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
await db.update(sync_configs)
|
||||
.set({ is_enabled: !config.is_enabled })
|
||||
.where(eq(sync_configs.id, config.id));
|
||||
|
||||
return c.redirect("/sync", 303);
|
||||
});
|
||||
|
||||
syncRouter.post("/sync/confirm", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const config = await getOrCreateSyncConfig(user.id);
|
||||
const filters = await db.select().from(sync_filters).where(eq(sync_filters.sync_config_id, config.id));
|
||||
|
||||
if (config.is_enabled && filters.length > 0) {
|
||||
await db.update(sync_configs)
|
||||
.set({ confirmed_at: new Date() })
|
||||
.where(eq(sync_configs.id, config.id));
|
||||
}
|
||||
|
||||
return c.redirect("/sync", 303);
|
||||
});
|
||||
120
web/src/routes/vk.ts
Normal file
120
web/src/routes/vk.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Hono } from "hono";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, vk_connections } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSessionUserId, type AppEnv } from "../lib/auth.js";
|
||||
import { render } from "../lib/render.js";
|
||||
import { config } from "../config.js";
|
||||
|
||||
export const vkRouter = new Hono<AppEnv>();
|
||||
|
||||
const VK_API_URL = "https://api.vk.com/method";
|
||||
const VK_OAUTH_URL = "https://oauth.vk.com/authorize";
|
||||
|
||||
async function requireUser(c: Parameters<typeof getSessionUserId>[0]) {
|
||||
const id = getSessionUserId(c);
|
||||
if (!id) return null;
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
async function fetchGroupInfo(token: string): Promise<{ groupId: string | null; groupName: string | null }> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
access_token: token,
|
||||
v: config.VK_API_VERSION,
|
||||
filter: "admin",
|
||||
extended: "1",
|
||||
count: "1",
|
||||
});
|
||||
const resp = await fetch(`${VK_API_URL}/groups.get?${params}`, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!resp.ok) return { groupId: null, groupName: null };
|
||||
const data = await resp.json() as { error?: unknown; response?: { items?: Array<{ id: number; name: string }> } };
|
||||
if (data.error) return { groupId: null, groupName: null };
|
||||
const items = data.response?.items ?? [];
|
||||
if (items.length === 0) return { groupId: null, groupName: null };
|
||||
return { groupId: String(items[0].id), groupName: items[0].name };
|
||||
} catch {
|
||||
return { groupId: null, groupName: null };
|
||||
}
|
||||
}
|
||||
|
||||
vkRouter.get("/vk", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const [connection] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
|
||||
const error = c.req.query("error") ?? null;
|
||||
|
||||
return c.html(render("vk.njk", {
|
||||
user,
|
||||
connection: connection ?? null,
|
||||
error,
|
||||
vk_client_id: config.VK_CLIENT_ID,
|
||||
callback_url: `${config.BASE_URL}/vk/callback`,
|
||||
}));
|
||||
});
|
||||
|
||||
vkRouter.get("/vk/connect", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
if (!config.VK_CLIENT_ID) return c.redirect("/vk?error=no_client_id", 303);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.VK_CLIENT_ID,
|
||||
scope: "market,groups",
|
||||
redirect_uri: `${config.BASE_URL}/vk/callback`,
|
||||
display: "page",
|
||||
response_type: "token",
|
||||
v: config.VK_API_VERSION,
|
||||
});
|
||||
return c.redirect(`${VK_OAUTH_URL}?${params}`, 302);
|
||||
});
|
||||
|
||||
vkRouter.get("/vk/callback", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
return c.html(render("vk_callback.njk", { user }));
|
||||
});
|
||||
|
||||
vkRouter.post("/vk/token", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
|
||||
const form = await c.req.formData();
|
||||
const token = String(form.get("token") ?? "").trim();
|
||||
if (!token) return c.redirect("/vk?error=empty_token", 303);
|
||||
|
||||
const { groupId, groupName } = await fetchGroupInfo(token);
|
||||
if (!groupId) return c.redirect("/vk?error=invalid_token", 303);
|
||||
|
||||
const now = new Date();
|
||||
const [existing] = await db.select().from(vk_connections).where(eq(vk_connections.user_id, user.id)).limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db.update(vk_connections)
|
||||
.set({ access_token: token, vk_user_id: groupId, first_name: groupName, last_name: null, is_online: true, last_checked_at: now, updated_at: now })
|
||||
.where(eq(vk_connections.id, existing.id));
|
||||
} else {
|
||||
await db.insert(vk_connections).values({
|
||||
user_id: user.id,
|
||||
access_token: token,
|
||||
vk_user_id: groupId,
|
||||
first_name: groupName,
|
||||
last_name: null,
|
||||
is_online: true,
|
||||
last_checked_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
return c.redirect("/connections", 303);
|
||||
});
|
||||
|
||||
vkRouter.post("/vk/disconnect", async (c) => {
|
||||
const user = await requireUser(c);
|
||||
if (!user) return c.redirect("/login", 303);
|
||||
await db.delete(vk_connections).where(eq(vk_connections.user_id, user.id));
|
||||
return c.redirect("/connections", 303);
|
||||
});
|
||||
99
web/src/templates/base.njk
Normal file
99
web/src/templates/base.njk
Normal file
@@ -0,0 +1,99 @@
|
||||
<!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>
|
||||
<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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,33 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base.njk" %}
|
||||
{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
|
||||
<li class="breadcrumb-item active">{{ store.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
|
||||
<li class="breadcrumb-item active">{{ store.name }}</li>
|
||||
</ol>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h1 class="h4 mb-0">Группы товаров</h1>
|
||||
<a href="/catalog/export?type=groups&store_id={{ store.evotor_id }}" class="btn btn-outline-secondary btn-sm">
|
||||
<div class="d-flex align-center justify-between mb-3">
|
||||
<h1 style="font-size:1.3rem; margin:0;">Группы товаров</h1>
|
||||
<a href="/catalog/export?type=groups&store_id={{ store.evotor_id }}" role="button" class="outline secondary sm">
|
||||
<i class="bi bi-download me-1"></i>Экспорт CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if not groups %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-folder fs-1 mb-3 d-block"></i>
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-folder empty-icon"></i>
|
||||
<p>Группы не найдены в этом магазине.</p>
|
||||
<a href="/catalog/products?store_id={{ store.evotor_id }}" class="btn btn-outline-primary btn-sm">
|
||||
<a href="/catalog/products?store_id={{ store.evotor_id }}" role="button" class="outline sm">
|
||||
Посмотреть все товары магазина
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<div class="table-scroll">
|
||||
<table class="align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Кол-во товаров</th>
|
||||
@@ -37,30 +36,30 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for group in groups %}
|
||||
{% set mode = filter_map.get(group.evotor_id) %}
|
||||
{% set mode = filter_map[group.evotor_id] %}
|
||||
<tr>
|
||||
<td>{{ group.name }}</td>
|
||||
<td class="text-muted">{{ product_counts.get(group.evotor_id, 0) }}</td>
|
||||
<td class="text-muted">{{ product_counts[group.evotor_id] or 0 }}</td>
|
||||
<td>
|
||||
{% if mode == "include" %}
|
||||
<span class="badge bg-success">✓ Включено</span>
|
||||
<span class="badge badge-success">✓ Включено</span>
|
||||
{% elif mode == "exclude" %}
|
||||
<span class="badge bg-danger">✗ Исключено</span>
|
||||
<span class="badge badge-danger">✗ Исключено</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-muted border">— Нет правила</span>
|
||||
<span class="badge badge-light">— Нет правила</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="d-flex gap-1 justify-content-end">
|
||||
<td>
|
||||
<div class="d-flex gap-1 justify-end">
|
||||
<a href="/catalog/products?store_id={{ store.evotor_id }}&group_id={{ group.evotor_id }}"
|
||||
class="btn btn-outline-secondary btn-sm" title="Товары">
|
||||
role="button" class="outline secondary sm" title="Товары">
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<div class="dropdown" data-dropdown>
|
||||
<button type="button" class="outline secondary sm" data-dropdown-toggle>
|
||||
<i class="bi bi-funnel"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<form method="post" action="/catalog/filter">
|
||||
<input type="hidden" name="entity_type" value="group">
|
||||
@@ -92,7 +91,7 @@
|
||||
<input type="hidden" name="filter_mode" value="none">
|
||||
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
|
||||
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
|
||||
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
|
||||
<button type="submit" class="dropdown-item muted">— Убрать правило</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -105,4 +104,16 @@
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('[data-dropdown]').forEach(function(dd) {
|
||||
dd.querySelector('[data-dropdown-toggle]').addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
dd.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', function() {
|
||||
document.querySelectorAll('[data-dropdown].open').forEach(function(dd) { dd.classList.remove('open'); });
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,23 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base.njk" %}
|
||||
{% block title %}Товары — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
|
||||
<li class="breadcrumb-item"><a href="/catalog/groups?store_id={{ store.evotor_id }}">{{ store.name }}</a></li>
|
||||
{% if group %}
|
||||
<li class="breadcrumb-item active">{{ group.name }}</li>
|
||||
{% else %}
|
||||
<li class="breadcrumb-item active">Все товары</li>
|
||||
{% endif %}
|
||||
</ol>
|
||||
</nav>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
|
||||
<li class="breadcrumb-item"><a href="/catalog/groups?store_id={{ store.evotor_id }}">{{ store.name }}</a></li>
|
||||
{% if group %}
|
||||
<li class="breadcrumb-item active">{{ group.name }}</li>
|
||||
{% else %}
|
||||
<li class="breadcrumb-item active">Все товары</li>
|
||||
{% endif %}
|
||||
</ol>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h1 class="h4 mb-0">Товары{% if group %}: {{ group.name }}{% endif %}</h1>
|
||||
<div class="d-flex align-center justify-between mb-3">
|
||||
<h1 style="font-size:1.3rem; margin:0;">Товары{% if group %}: {{ group.name }}{% endif %}</h1>
|
||||
<a href="/catalog/export?type=products&store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
role="button" class="outline secondary sm">
|
||||
<i class="bi bi-download me-1"></i>Экспорт CSV
|
||||
</a>
|
||||
</div>
|
||||
@@ -25,14 +23,15 @@
|
||||
{% set redirect_back %}/catalog/products?store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}{% endset %}
|
||||
|
||||
{% if not products %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-box fs-1 mb-3 d-block"></i>
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-box empty-icon"></i>
|
||||
<p>Товары не найдены.</p>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="table-responsive" style="overflow: visible;">
|
||||
<table class="table table-striped table-hover align-middle small">
|
||||
<thead class="table-light">
|
||||
<div class="table-scroll">
|
||||
<table class="align-middle small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Артикул</th>
|
||||
@@ -47,15 +46,15 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for product in products %}
|
||||
{% set mode = filter_map.get(product.evotor_id) %}
|
||||
{% set mode = filter_map[product.evotor_id] %}
|
||||
<tr>
|
||||
<td>{{ product.name }}</td>
|
||||
<td class="text-muted font-monospace">{{ product.article_number or "—" }}</td>
|
||||
<td>{% if product.price %}{{ "%.2f"|format(product.price) }} ₽{% else %}—{% endif %}</td>
|
||||
<td>{% if product.quantity is not none %}{{ product.quantity }}{% else %}—{% endif %}</td>
|
||||
<td>{% if product.price %}{{ product.price | price }} ₽{% else %}—{% endif %}</td>
|
||||
<td>{% if product.quantity != null %}{{ product.quantity }}{% else %}—{% endif %}</td>
|
||||
<td class="text-muted">{{ product.measure_name or "—" }}</td>
|
||||
<td>
|
||||
{% if product.allow_to_sell is none %}
|
||||
{% if product.allow_to_sell == null %}
|
||||
<span class="text-muted">—</span>
|
||||
{% elif product.allow_to_sell %}
|
||||
<i class="bi bi-check-circle-fill text-success"></i>
|
||||
@@ -65,7 +64,7 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if product.synced_at %}
|
||||
<span title="{{ product.synced_at.strftime('%d.%m.%Y %H:%M') }}">
|
||||
<span title="{{ product.synced_at | datefmt }}">
|
||||
<i class="bi bi-check-circle-fill text-success"></i>
|
||||
</span>
|
||||
{% else %}
|
||||
@@ -74,19 +73,19 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if mode == "include" %}
|
||||
<span class="badge bg-success">✓ Включено</span>
|
||||
<span class="badge badge-success">✓ Включено</span>
|
||||
{% elif mode == "exclude" %}
|
||||
<span class="badge bg-danger">✗ Исключено</span>
|
||||
<span class="badge badge-danger">✗ Исключено</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-muted border">— Нет правила</span>
|
||||
<span class="badge badge-light">— Нет правила</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" data-bs-strategy="fixed">
|
||||
<div class="dropdown" data-dropdown>
|
||||
<button type="button" class="outline secondary sm" data-dropdown-toggle>
|
||||
<i class="bi bi-funnel"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<form method="post" action="/catalog/filter">
|
||||
<input type="hidden" name="entity_type" value="product">
|
||||
@@ -118,7 +117,7 @@
|
||||
<input type="hidden" name="filter_mode" value="none">
|
||||
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
|
||||
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
|
||||
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
|
||||
<button type="submit" class="dropdown-item muted">— Убрать правило</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -130,4 +129,16 @@
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('[data-dropdown]').forEach(function(dd) {
|
||||
dd.querySelector('[data-dropdown-toggle]').addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
dd.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', function() {
|
||||
document.querySelectorAll('[data-dropdown].open').forEach(function(dd) { dd.classList.remove('open'); });
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,17 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base.njk" %}
|
||||
{% block title %}Каталог — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h1 class="h4 mb-0">Каталог</h1>
|
||||
<div class="d-flex align-center justify-between mb-3">
|
||||
<h1 style="font-size:1.3rem; margin:0;">Каталог</h1>
|
||||
<div class="d-flex gap-2">
|
||||
{% if evotor %}
|
||||
<form method="post" action="/catalog/refresh">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<button type="submit" class="outline secondary sm">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Обновить
|
||||
</button>
|
||||
</form>
|
||||
<a href="/catalog/export?type=stores" class="btn btn-outline-secondary btn-sm">
|
||||
<a href="/catalog/export?type=stores" role="button" class="outline secondary sm">
|
||||
<i class="bi bi-download me-1"></i>Экспорт CSV
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -19,23 +19,25 @@
|
||||
</div>
|
||||
|
||||
{% if not evotor %}
|
||||
<div class="alert alert-warning d-flex align-items-center gap-2">
|
||||
<div role="alert" class="alert alert-warning d-flex align-center gap-2">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
<span>Эвотор не подключён. <a href="/evotor">Подключить Эвотор</a></span>
|
||||
</div>
|
||||
|
||||
{% elif not stores %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-shop fs-1 mb-3 d-block"></i>
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-shop empty-icon"></i>
|
||||
<p>Магазины не найдены в вашем аккаунте Эвотор.</p>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{% if fetched_at %}
|
||||
<p class="text-muted small mb-3">Последнее обновление: {{ fetched_at.strftime("%d.%m.%Y %H:%M") }}</p>
|
||||
<p class="text-muted small mb-3">Последнее обновление: {{ fetched_at | datefmt }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<div class="table-scroll">
|
||||
<table class="align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Адрес</th>
|
||||
@@ -45,30 +47,30 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for store in stores %}
|
||||
{% set mode = filter_map.get(store.evotor_id) %}
|
||||
{% set mode = filter_map[store.evotor_id] %}
|
||||
<tr>
|
||||
<td>{{ store.name }}</td>
|
||||
<td class="text-muted small">{{ store.address or "—" }}</td>
|
||||
<td>
|
||||
{% if mode == "include" %}
|
||||
<span class="badge bg-success">✓ Включено</span>
|
||||
<span class="badge badge-success">✓ Включено</span>
|
||||
{% elif mode == "exclude" %}
|
||||
<span class="badge bg-danger">✗ Исключено</span>
|
||||
<span class="badge badge-danger">✗ Исключено</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-muted border">— Нет правила</span>
|
||||
<span class="badge badge-light">— Нет правила</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="d-flex gap-1 justify-content-end">
|
||||
<td>
|
||||
<div class="d-flex gap-1 justify-end">
|
||||
<a href="/catalog/groups?store_id={{ store.evotor_id }}"
|
||||
class="btn btn-outline-secondary btn-sm" title="Группы">
|
||||
role="button" class="outline secondary sm" title="Группы">
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</a>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<div class="dropdown" data-dropdown>
|
||||
<button type="button" class="outline secondary sm" data-dropdown-toggle>
|
||||
<i class="bi bi-funnel"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<form method="post" action="/catalog/filter">
|
||||
<input type="hidden" name="entity_type" value="store">
|
||||
@@ -97,7 +99,7 @@
|
||||
<input type="hidden" name="entity_name" value="{{ store.name }}">
|
||||
<input type="hidden" name="filter_mode" value="none">
|
||||
<input type="hidden" name="redirect_to" value="/catalog">
|
||||
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
|
||||
<button type="submit" class="dropdown-item muted">— Убрать правило</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -110,4 +112,16 @@
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('[data-dropdown]').forEach(function(dd) {
|
||||
dd.querySelector('[data-dropdown-toggle]').addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
dd.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', function() {
|
||||
document.querySelectorAll('[data-dropdown].open').forEach(function(dd) { dd.classList.remove('open'); });
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
16
web/src/templates/confirm_email.njk
Normal file
16
web/src/templates/confirm_email.njk
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.njk" %}
|
||||
{% 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 %}
|
||||
63
web/src/templates/connections.njk
Normal file
63
web/src/templates/connections.njk
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "base.njk" %}
|
||||
{% block title %}Подключения — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-center justify-between mb-4">
|
||||
<h1 style="font-size:1.3rem; margin:0;">Подключения</h1>
|
||||
<a href="/connections/add" role="button" class="sm">
|
||||
<i class="bi bi-plus-lg me-1"></i>Добавить
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if connections %}
|
||||
<div class="row gap-3">
|
||||
{% for conn in connections %}
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<article class="card h-100">
|
||||
<div class="card-body d-flex flex-col" style="gap: 0.75rem;">
|
||||
<div class="d-flex align-center" style="gap: 0.75rem;">
|
||||
<i class="bi {{ conn.icon }} fs-2 text-secondary"></i>
|
||||
<div class="flex-1">
|
||||
<strong>{{ conn.name }}</strong>
|
||||
{% if conn.details %}
|
||||
<br><small class="text-muted">{{ conn.details }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if conn.is_online %}
|
||||
<i class="bi bi-circle-fill text-success fs-5" title="Онлайн"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-circle-fill text-danger fs-5" title="Офлайн"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ conn.configure_url }}" role="button" class="outline sm flex-fill">Настроить</a>
|
||||
<form method="post" action="/connections/delete?type={{ conn.type }}">
|
||||
<button type="submit" class="outline danger sm"
|
||||
onclick="return confirm('Вы уверены, что хотите отключить {{ conn.name }}?')">
|
||||
Отключить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="text-muted small">
|
||||
{% if conn.last_checked_at %}
|
||||
Проверено: {{ conn.last_checked_at | datefmt }}
|
||||
{% else %}
|
||||
Статус ещё не проверялся
|
||||
{% endif %}
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-plug empty-icon"></i>
|
||||
<p class="mb-3">Нет подключённых сервисов</p>
|
||||
<a href="/connections/add" role="button">
|
||||
<i class="bi bi-plus-lg me-1"></i>Добавить подключение
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
39
web/src/templates/connections_add.njk
Normal file
39
web/src/templates/connections_add.njk
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "base.njk" %}
|
||||
{% block title %}Добавить подключение — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-center mb-4" style="gap: 0.75rem;">
|
||||
<a href="/connections" class="text-muted"><i class="bi bi-arrow-left fs-5"></i></a>
|
||||
<h1 style="font-size:1.3rem; margin:0;">Добавить подключение</h1>
|
||||
</div>
|
||||
|
||||
{% if available %}
|
||||
<div class="row gap-3">
|
||||
{% for svc in available %}
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<article class="card h-100">
|
||||
<div class="card-body d-flex flex-col" style="gap: 0.75rem;">
|
||||
<div class="d-flex align-center" style="gap: 0.75rem;">
|
||||
<i class="bi {{ svc.icon }} fs-2 text-secondary"></i>
|
||||
<strong>{{ svc.name }}</strong>
|
||||
</div>
|
||||
<p class="text-muted small flex-1">{{ svc.description }}</p>
|
||||
<a href="{{ svc.connect_url }}" role="button" class="sm" style="margin-top: auto;">
|
||||
Подключить <i class="bi bi-arrow-right ms-auto"></i>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-check-circle empty-icon text-success"></i>
|
||||
<p class="mb-3">Все доступные сервисы подключены</p>
|
||||
<a href="/connections" role="button" class="outline secondary sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
17
web/src/templates/email_confirmed.njk
Normal file
17
web/src/templates/email_confirmed.njk
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "base.njk" %}
|
||||
{% 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 %}
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base.njk" %}
|
||||
{% block title %}Подключение Эвотор — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="row justify-center">
|
||||
<div class="col-sm-10 col-md-7 col-lg-6">
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger mt-4">
|
||||
<div role="alert" class="alert alert-danger mt-4">
|
||||
{% if error == "invalid_token" %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова.
|
||||
{% elif error == "empty_token" %}
|
||||
@@ -17,58 +17,55 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header">
|
||||
<h1 class="h5 mb-0">Подключение Эвотор</h1>
|
||||
</div>
|
||||
<article class="card mt-4">
|
||||
<header>
|
||||
<h1>Подключение Эвотор</h1>
|
||||
</header>
|
||||
|
||||
{% if connection %}
|
||||
{# ── CONNECTED STATE ── #}
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<span class="text-muted small">Статус</span>
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
||||
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
||||
</li>
|
||||
{% if connection.store_name %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<li class="list-group-item">
|
||||
<span class="text-muted small">Магазин</span>
|
||||
<span>{{ connection.store_name }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if connection.store_id %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<li class="list-group-item">
|
||||
<span class="text-muted small">ID магазина</span>
|
||||
<span class="font-monospace small text-muted">{{ connection.store_id }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<li class="list-group-item">
|
||||
<span class="text-muted small">Подключено</span>
|
||||
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
||||
<span class="small">{{ connection.connected_at | datefmt }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="card-footer">
|
||||
<footer>
|
||||
<p class="text-muted small mb-2">Обновить токен (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p>
|
||||
<form method="post" action="/evotor/token">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required>
|
||||
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
|
||||
<div class="input-group">
|
||||
<input type="text" name="token" class="font-monospace" placeholder="Новый токен" required>
|
||||
<button type="submit" class="outline secondary">Обновить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body d-grid">
|
||||
</footer>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/evotor/disconnect">
|
||||
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
|
||||
<button type="submit" class="outline danger w-100">Отключить аккаунт Эвотор</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ── NOT CONNECTED STATE ── #}
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">
|
||||
Для подключения вам нужно установить приложение <strong>ЭвоСинк</strong> в личном кабинете Эвотор
|
||||
и скопировать токен доступа из его настроек.
|
||||
</p>
|
||||
|
||||
<ol class="text-muted small mb-4">
|
||||
{% if app_url %}
|
||||
<li class="mb-1">Откройте <a href="{{ app_url }}" target="_blank" rel="noopener">приложение ЭвоСинк в магазине Эвотор <i class="bi bi-box-arrow-up-right small"></i></a> и установите его.</li>
|
||||
@@ -78,27 +75,21 @@
|
||||
<li class="mb-1">Перейдите в раздел <strong>Приложения → ЭвоСинк → Настройки</strong>.</li>
|
||||
<li class="mb-1">Скопируйте токен доступа и вставьте его в поле ниже.</li>
|
||||
</ol>
|
||||
|
||||
<form method="post" action="/evotor/token">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted">Токен доступа</label>
|
||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен Эвотор" required autofocus>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Подключить</button>
|
||||
</div>
|
||||
<label class="small text-muted">Токен доступа
|
||||
<input type="text" name="token" class="font-monospace" placeholder="Вставьте токен Эвотор" required autofocus>
|
||||
</label>
|
||||
<button type="submit" class="w-100">Подключить</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="mt-3 text-center">
|
||||
<a href="/connections" class="text-muted small">
|
||||
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
web/src/templates/forgot_password.njk
Normal file
24
web/src/templates/forgot_password.njk
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "base.njk" %}
|
||||
{% 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 %}
|
||||
27
web/src/templates/login.njk
Normal file
27
web/src/templates/login.njk
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.njk" %}
|
||||
{% 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/src/templates/message.njk
Normal file
18
web/src/templates/message.njk
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "base.njk" %}
|
||||
{% 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/src/templates/profile_change_password.njk
Normal file
31
web/src/templates/profile_change_password.njk
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "base.njk" %}
|
||||
{% 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/src/templates/profile_delete.njk
Normal file
31
web/src/templates/profile_delete.njk
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "base.njk" %}
|
||||
{% 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/src/templates/profile_edit.njk
Normal file
43
web/src/templates/profile_edit.njk
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "base.njk" %}
|
||||
{% 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 %}
|
||||
@@ -1,46 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base.njk" %}
|
||||
{% block title %}Личный кабинет — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="row justify-center">
|
||||
<div class="col-sm-10 col-md-7 col-lg-6">
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header">
|
||||
<h1 class="h5 mb-0"><i class="bi bi-person-circle me-2"></i>Личный кабинет</h1>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<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 d-flex justify-content-between">
|
||||
<li class="list-group-item">
|
||||
<span class="text-muted small">Фамилия</span>
|
||||
<span>{{ user.last_name }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<li class="list-group-item">
|
||||
<span class="text-muted small">Email</span>
|
||||
<span>{{ user.email }}</span>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between">
|
||||
<li class="list-group-item">
|
||||
<span class="text-muted small">Телефон</span>
|
||||
<span>{{ user.phone }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="card-body d-grid gap-2">
|
||||
<a href="/profile/edit" class="btn btn-primary">
|
||||
<a href="/profile/edit" role="button">
|
||||
<i class="bi bi-pencil me-1"></i>Редактировать профиль
|
||||
</a>
|
||||
<a href="/profile/change-password" class="btn btn-secondary">
|
||||
<a href="/profile/change-password" role="button" class="secondary">
|
||||
<i class="bi bi-key me-1"></i>Изменить пароль
|
||||
</a>
|
||||
<a href="/logout" class="btn btn-outline-secondary">
|
||||
<a href="/logout" role="button" class="outline secondary">
|
||||
<i class="bi bi-box-arrow-right me-1"></i>Выход
|
||||
</a>
|
||||
<a href="/profile/delete" class="btn btn-outline-danger btn-sm mt-2">
|
||||
<a href="/profile/delete" role="button" class="outline danger sm mt-2">
|
||||
<i class="bi bi-trash me-1"></i>Удалить аккаунт
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
44
web/src/templates/register.njk
Normal file
44
web/src/templates/register.njk
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends "base.njk" %}
|
||||
{% 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/src/templates/reset_password.njk
Normal file
23
web/src/templates/reset_password.njk
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "base.njk" %}
|
||||
{% 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 %}
|
||||
@@ -1,67 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base.njk" %}
|
||||
{% block title %}Синхронизация — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="h4 mb-4">Синхронизация</h1>
|
||||
<h1 style="font-size:1.3rem;" class="mb-4">Синхронизация</h1>
|
||||
|
||||
{% if not evotor %}
|
||||
<div class="alert alert-warning d-flex align-items-center gap-2">
|
||||
<div role="alert" class="alert alert-warning d-flex align-center gap-2">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
<span>Эвотор не подключён. <a href="/evotor">Подключить Эвотор</a></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not vk %}
|
||||
<div class="alert alert-warning d-flex align-items-center gap-2">
|
||||
<div role="alert" class="alert alert-warning d-flex align-center gap-2">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
<span>ВКонтакте не подключён. <a href="/vk">Подключить ВКонтакте</a></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
{# ── Status card ── #}
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="row gap-3">
|
||||
<div class="col-12 col-md-6-auto">
|
||||
<article class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">Статус</h5>
|
||||
|
||||
<h5 class="mb-3">Статус</h5>
|
||||
<div class="mb-3">
|
||||
{% if status == "active" %}
|
||||
<span class="badge bg-success fs-6"><i class="bi bi-play-fill me-1"></i>Активна</span>
|
||||
<span class="badge badge-success fs-6"><i class="bi bi-play-fill me-1"></i>Активна</span>
|
||||
{% elif status == "paused" %}
|
||||
<span class="badge bg-secondary fs-6"><i class="bi bi-pause-fill me-1"></i>Приостановлена</span>
|
||||
<span class="badge badge-secondary fs-6"><i class="bi bi-pause-fill me-1"></i>Приостановлена</span>
|
||||
{% elif status == "pending" %}
|
||||
<span class="badge bg-warning text-dark fs-6"><i class="bi bi-clock me-1"></i>Ожидает подтверждения</span>
|
||||
<span class="badge badge-warning fs-6"><i class="bi bi-clock me-1"></i>Ожидает подтверждения</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark border fs-6"><i class="bi bi-gear me-1"></i>Не настроено</span>
|
||||
<span class="badge badge-light fs-6"><i class="bi bi-gear me-1"></i>Не настроено</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if config.confirmed_at %}
|
||||
<p class="text-muted small mb-3">
|
||||
Запущена: {{ config.confirmed_at.strftime("%d.%m.%Y %H:%M") }}
|
||||
Запущена: {{ config.confirmed_at | datefmt }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
{# Toggle enable/disable #}
|
||||
<form method="post" action="/sync/toggle">
|
||||
{% if config.is_enabled %}
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<button type="submit" class="outline secondary sm">
|
||||
<i class="bi bi-pause-fill me-1"></i>Приостановить
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm" {% if not evotor or not vk %}disabled{% endif %}>
|
||||
<button type="submit" class="outline sm" {% if not evotor or not vk %}disabled{% endif %}>
|
||||
<i class="bi bi-play-fill me-1"></i>Включить
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{# Confirm button #}
|
||||
{% if config.is_enabled and summary.total > 0 %}
|
||||
<form method="post" action="/sync/confirm">
|
||||
<button type="submit" class="btn btn-success btn-sm">
|
||||
<button type="submit" class="sm" style="--pico-background-color: #15803d; --pico-border-color: #15803d;">
|
||||
<i class="bi bi-check-lg me-1"></i>Подтвердить и запустить
|
||||
</button>
|
||||
</form>
|
||||
@@ -74,17 +69,15 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{# ── Filters card ── #}
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="col-12 col-md-6-auto">
|
||||
<article class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">Фильтры</h5>
|
||||
|
||||
<h5 class="mb-3">Фильтры</h5>
|
||||
{% if summary.total > 0 %}
|
||||
<ul class="list-unstyled mb-3">
|
||||
<ul style="list-style: none; padding: 0; margin-bottom: 0.75rem;">
|
||||
{% if summary.stores > 0 %}
|
||||
<li class="text-muted small"><i class="bi bi-shop me-1"></i>Магазины: {{ summary.stores }} правил</li>
|
||||
{% endif %}
|
||||
@@ -98,13 +91,11 @@
|
||||
{% else %}
|
||||
<p class="text-muted small mb-3">Фильтры не настроены — будут синхронизированы все товары.</p>
|
||||
{% endif %}
|
||||
|
||||
<a href="/catalog" class="btn btn-outline-primary btn-sm" {% if not evotor %}disabled{% endif %}>
|
||||
<a href="/catalog" role="button" class="outline sm" {% if not evotor %}disabled{% endif %}>
|
||||
<i class="bi bi-sliders me-1"></i>Настроить фильтры
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base.njk" %}
|
||||
{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="row justify-center">
|
||||
<div class="col-sm-10 col-md-7 col-lg-6">
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger mt-4">
|
||||
<div role="alert" class="alert alert-danger mt-4">
|
||||
{% if error == "invalid_token" %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен или у него нет прав администратора сообщества.
|
||||
{% elif error == "empty_token" %}
|
||||
@@ -19,63 +19,59 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header">
|
||||
<h1 class="h5 mb-0">Подключение ВКонтакте</h1>
|
||||
</div>
|
||||
<article class="card mt-4">
|
||||
<header>
|
||||
<h1>Подключение ВКонтакте</h1>
|
||||
</header>
|
||||
|
||||
{% if connection %}
|
||||
{# ── CONNECTED STATE ── #}
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<span class="text-muted small">Статус</span>
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
||||
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
||||
</li>
|
||||
{% if connection.first_name %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<li class="list-group-item">
|
||||
<span class="text-muted small">Сообщество</span>
|
||||
<span>{{ connection.first_name }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if connection.vk_user_id %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<li class="list-group-item">
|
||||
<span class="text-muted small">ID сообщества</span>
|
||||
<span class="font-monospace small text-muted">{{ connection.vk_user_id }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<li class="list-group-item">
|
||||
<span class="text-muted small">Подключено</span>
|
||||
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
||||
<span class="small">{{ connection.connected_at | datefmt }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="card-footer">
|
||||
<footer>
|
||||
<p class="text-muted small mb-2">Обновить токен пользователя:</p>
|
||||
<form method="post" action="/vk/token">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен пользователя" required>
|
||||
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
|
||||
<div class="input-group">
|
||||
<input type="text" name="token" class="font-monospace" placeholder="Новый токен пользователя" required>
|
||||
<button type="submit" class="outline secondary">Обновить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body d-grid">
|
||||
</footer>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/vk/disconnect">
|
||||
<button type="submit" class="btn btn-outline-danger w-100">Отключить ВКонтакте</button>
|
||||
<button type="submit" class="outline danger w-100">Отключить ВКонтакте</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ── NOT CONNECTED STATE ── #}
|
||||
<div class="card-body">
|
||||
{% if vk_client_id %}
|
||||
<p class="text-muted mb-4">
|
||||
Нажмите кнопку ниже, чтобы авторизоваться через ВКонтакте и выдать доступ к управлению товарами вашего сообщества.
|
||||
</p>
|
||||
<div class="d-grid mb-3">
|
||||
<a href="/vk/connect" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Подключить ВКонтакте
|
||||
</a>
|
||||
</div>
|
||||
<hr class="my-4">
|
||||
<a href="/vk/connect" role="button" class="w-100 mb-3" style="display: block; text-align: center;">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Подключить ВКонтакте
|
||||
</a>
|
||||
<hr>
|
||||
<p class="text-muted small mb-2">Или введите токен вручную:</p>
|
||||
{% else %}
|
||||
<p class="text-muted mb-3">
|
||||
@@ -85,25 +81,24 @@
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/vk/token">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted">Токен пользователя ВКонтакте</label>
|
||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен пользователя" required {% if vk_client_id %}{% else %}autofocus{% endif %}>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn {% if vk_client_id %}btn-outline-secondary{% else %}btn-primary{% endif %}">Подключить</button>
|
||||
</div>
|
||||
<label class="small text-muted">Токен пользователя ВКонтакте
|
||||
<input type="text" name="token" class="font-monospace" placeholder="Вставьте токен пользователя" required {% if not vk_client_id %}autofocus{% endif %}>
|
||||
</label>
|
||||
{% if vk_client_id %}
|
||||
<button type="submit" class="w-100 outline secondary">Подключить</button>
|
||||
{% else %}
|
||||
<button type="submit" class="w-100">Подключить</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="mt-3 text-center">
|
||||
<a href="/connections" class="text-muted small">
|
||||
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,22 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base.njk" %}
|
||||
{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="row justify-center">
|
||||
<div class="col-sm-10 col-md-7 col-lg-6">
|
||||
<div class="card shadow-sm mt-4 text-center">
|
||||
<div class="card-body py-5">
|
||||
<article class="card mt-4 text-center">
|
||||
<div class="card-body" style="padding: 2.5rem;">
|
||||
<div id="state-loading">
|
||||
<div class="spinner-border text-primary mb-3" role="status"></div>
|
||||
<div class="spinner mb-3" style="margin: 0 auto;"></div>
|
||||
<p class="text-muted mb-0">Подключение ВКонтакте…</p>
|
||||
</div>
|
||||
<div id="state-error" class="d-none">
|
||||
<i class="bi bi-exclamation-triangle-fill text-danger fs-1 mb-3 d-block"></i>
|
||||
<p class="text-muted mb-3" id="error-message">Не удалось получить токен от ВКонтакте.</p>
|
||||
<a href="/vk" class="btn btn-outline-secondary">Попробовать снова</a>
|
||||
<a href="/vk" role="button" class="outline secondary">Попробовать снова</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,431 @@
|
||||
/* Brand overrides */
|
||||
/* Brand colors */
|
||||
:root {
|
||||
--bs-primary: #F05023;
|
||||
--bs-primary-rgb: 240, 80, 35;
|
||||
--bs-link-color: #0986E2;
|
||||
--bs-link-hover-color: #0670c0;
|
||||
--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: 22px;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #F05023 !important;
|
||||
color: var(--brand-primary) !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-border {
|
||||
border-color: #F05023 !important;
|
||||
.nav-links {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
--bs-btn-bg: #F05023;
|
||||
--bs-btn-border-color: #F05023;
|
||||
--bs-btn-hover-bg: #d44420;
|
||||
--bs-btn-hover-border-color: #d44420;
|
||||
--bs-btn-active-bg: #c03d1c;
|
||||
--bs-btn-active-border-color: #c03d1c;
|
||||
.nav-links a {
|
||||
color: var(--pico-color);
|
||||
text-decoration: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
--bs-btn-bg: #0986E2;
|
||||
--bs-btn-border-color: #0986E2;
|
||||
--bs-btn-hover-bg: #0770c0;
|
||||
--bs-btn-hover-border-color: #0770c0;
|
||||
--bs-btn-active-bg: #065fa3;
|
||||
--bs-btn-active-border-color: #065fa3;
|
||||
.nav-links a:hover {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #F05023 !important;
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
"""
|
||||
Sync engine: syncs Evotor products to VK market for all enabled users.
|
||||
Runs as a background asyncio loop inside the web app.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.database import SessionLocal
|
||||
from web.models import CachedProduct, EvotorConnection, VkConnection, SyncConfig, SyncFilter
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
VK_API_HOST = "https://api.vk.ru/method"
|
||||
VK_API_VERSION = "5.199"
|
||||
EVOTOR_API_BASE = "https://api.evotor.ru"
|
||||
VK_CATEGORY_ID = 40932
|
||||
VK_STOCK_AMOUNT = 1000
|
||||
WEIGHT_PRICE_MULTIPLIER = 10
|
||||
WEIGHT_MEASURES = {"г", "г.", "грамм", "граммов", "гр", "гр."}
|
||||
|
||||
|
||||
def _is_weight_measure(measure: str | None) -> bool:
|
||||
if not measure:
|
||||
return False
|
||||
return measure.strip().lower() in WEIGHT_MEASURES
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
return name.strip().replace(";", ",")
|
||||
|
||||
|
||||
def _calc_price(price_kopecks, measure: str | None) -> tuple[int, str]:
|
||||
"""Returns (price_in_kopecks_for_vk, price_info_label)."""
|
||||
base = int(price_kopecks or 0)
|
||||
if _is_weight_measure(measure):
|
||||
return base * WEIGHT_PRICE_MULTIPLIER, f"{WEIGHT_PRICE_MULTIPLIER}{measure}"
|
||||
return base, measure or ""
|
||||
|
||||
|
||||
def _build_description(name: str, price_info: str, extra_desc: str | None) -> str:
|
||||
desc = f"{name} (цена за {price_info}.)\n\n"
|
||||
if extra_desc:
|
||||
desc += extra_desc
|
||||
return desc
|
||||
|
||||
|
||||
def _get_included_store_ids(filters: list) -> list[str]:
|
||||
return [f.entity_id for f in filters if f.entity_type == "store" and f.filter_mode == "include"]
|
||||
|
||||
|
||||
def _is_group_included(group_id: str | None, filters: list) -> bool:
|
||||
"""Returns True if the group should be synced based on filters."""
|
||||
group_filters = {f.entity_id: f.filter_mode for f in filters if f.entity_type == "group"}
|
||||
if not group_filters:
|
||||
return True # no group filters → include all
|
||||
mode = group_filters.get(group_id)
|
||||
if mode == "exclude":
|
||||
return False
|
||||
if mode == "include":
|
||||
return True
|
||||
# Not mentioned — include if there are only excludes, exclude if there are only includes
|
||||
has_includes = any(v == "include" for v in group_filters.values())
|
||||
return not has_includes
|
||||
|
||||
|
||||
def _is_product_included(product_id: str, filters: list) -> bool:
|
||||
product_filters = {f.entity_id: f.filter_mode for f in filters if f.entity_type == "product"}
|
||||
if not product_filters:
|
||||
return True
|
||||
mode = product_filters.get(product_id)
|
||||
if mode == "exclude":
|
||||
return False
|
||||
if mode == "include":
|
||||
return True
|
||||
has_includes = any(v == "include" for v in product_filters.values())
|
||||
return not has_includes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evotor API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _evo_fetch_products(token: str, store_id: str) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{EVOTOR_API_BASE}/stores/{store_id}/products",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code in (402, 404):
|
||||
return []
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("items", data) if isinstance(data, dict) else data
|
||||
|
||||
|
||||
async def _evo_fetch_groups(token: str, store_id: str) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{EVOTOR_API_BASE}/stores/{store_id}/product-groups",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code in (402, 404):
|
||||
return []
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("items", data) if isinstance(data, dict) else data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VK API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _vk_params(token: str, **extra) -> dict:
|
||||
return {"access_token": token, "v": VK_API_VERSION, **extra}
|
||||
|
||||
|
||||
async def _vk_get_albums(token: str, owner_id: str) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{VK_API_HOST}/market.getAlbums",
|
||||
params=_vk_params(token, owner_id=owner_id, count=200),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("response", {}).get("items", [])
|
||||
|
||||
|
||||
async def _vk_create_album(token: str, owner_id: str, title: str) -> int | None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{VK_API_HOST}/market.addAlbum",
|
||||
data=_vk_params(token, owner_id=owner_id, title=title),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
logger.error("VK create album error: %s", data["error"])
|
||||
return None
|
||||
return data.get("response", {}).get("market_album_id")
|
||||
|
||||
|
||||
async def _vk_get_products(token: str, owner_id: str) -> list[dict]:
|
||||
"""Fetch all VK market items (handles pagination)."""
|
||||
items = []
|
||||
offset = 0
|
||||
count = 200
|
||||
async with httpx.AsyncClient() as client:
|
||||
while True:
|
||||
resp = await client.get(
|
||||
f"{VK_API_HOST}/market.get",
|
||||
params=_vk_params(token, owner_id=owner_id, extended=1,
|
||||
with_disabled=1, count=count, offset=offset),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
batch = data.get("response", {}).get("items", [])
|
||||
items.extend(batch)
|
||||
if len(batch) < count:
|
||||
break
|
||||
offset += count
|
||||
return items
|
||||
|
||||
|
||||
async def _vk_upload_photo(token: str, group_id: str, photo_path: str) -> str | None:
|
||||
"""Upload a photo and return photo_id."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Get upload URL
|
||||
resp = await client.get(
|
||||
f"{VK_API_HOST}/market.getProductPhotoUploadServer",
|
||||
params=_vk_params(token, group_id=group_id),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
logger.error("VK get upload URL error: %s", data["error"])
|
||||
return None
|
||||
upload_url = data.get("response", {}).get("upload_url")
|
||||
if not upload_url:
|
||||
return None
|
||||
|
||||
# Upload photo
|
||||
with open(photo_path, "rb") as f:
|
||||
upload_resp = await client.post(upload_url, files={"file": f}, timeout=60)
|
||||
upload_resp.raise_for_status()
|
||||
upload_obj = upload_resp.json()
|
||||
|
||||
# Save photo
|
||||
save_resp = await client.post(
|
||||
f"{VK_API_HOST}/market.saveProductPhoto",
|
||||
data=_vk_params(token, upload_response=upload_resp.text),
|
||||
timeout=30,
|
||||
)
|
||||
save_resp.raise_for_status()
|
||||
save_data = save_resp.json()
|
||||
if "error" in save_data:
|
||||
logger.error("VK save photo error: %s", save_data["error"])
|
||||
return None
|
||||
return save_data.get("response", {}).get("photo_id")
|
||||
|
||||
|
||||
async def _vk_create_product(
|
||||
token: str, owner_id: str, name: str, description: str,
|
||||
price: int, stock_amount: int, photo_id: str, album_id: int | None,
|
||||
) -> int | None:
|
||||
params = _vk_params(
|
||||
token,
|
||||
owner_id=owner_id,
|
||||
name=name,
|
||||
description=description,
|
||||
category_id=VK_CATEGORY_ID,
|
||||
price=price,
|
||||
main_photo_id=photo_id,
|
||||
stock_amount=stock_amount,
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(f"{VK_API_HOST}/market.add", data=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
logger.error("VK create product error: %s", data["error"])
|
||||
return None
|
||||
product_id = data.get("response", {}).get("market_item_id")
|
||||
if product_id and album_id:
|
||||
await client.get(
|
||||
f"{VK_API_HOST}/market.addToAlbum",
|
||||
params=_vk_params(token, owner_id=owner_id,
|
||||
item_ids=product_id, album_ids=album_id),
|
||||
timeout=30,
|
||||
)
|
||||
return product_id
|
||||
|
||||
|
||||
async def _vk_edit_product(
|
||||
token: str, owner_id: str, item_id: int, name: str,
|
||||
description: str, price: int, stock_amount: int,
|
||||
) -> None:
|
||||
params = _vk_params(
|
||||
token,
|
||||
owner_id=owner_id,
|
||||
item_id=item_id,
|
||||
name=name,
|
||||
description=description,
|
||||
category_id=VK_CATEGORY_ID,
|
||||
price=price,
|
||||
stock_amount=stock_amount,
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(f"{VK_API_HOST}/market.edit", data=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
logger.error("VK edit product error: %s", data["error"])
|
||||
|
||||
|
||||
async def _vk_delete_product(token: str, owner_id: str, item_id: int) -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{VK_API_HOST}/market.delete",
|
||||
data=_vk_params(token, owner_id=owner_id, item_id=item_id),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main sync logic per user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _stamp_synced(db: Session, user_id: int, evo_id: str, now: datetime) -> None:
|
||||
db.query(CachedProduct).filter(
|
||||
CachedProduct.user_id == user_id,
|
||||
CachedProduct.evotor_id == evo_id,
|
||||
).update({"synced_at": now})
|
||||
db.commit()
|
||||
|
||||
|
||||
async def sync_user(
|
||||
user_id: int,
|
||||
evo_token: str,
|
||||
vk_token: str,
|
||||
vk_group_id: str,
|
||||
filters: list,
|
||||
photo_path: str,
|
||||
db: Session,
|
||||
) -> None:
|
||||
owner_id = f"-{vk_group_id}"
|
||||
now = datetime.utcnow()
|
||||
logger.info("Sync start: user_id=%d vk_group=%s", user_id, vk_group_id)
|
||||
|
||||
store_ids = _get_included_store_ids(filters)
|
||||
if not store_ids:
|
||||
logger.info("Sync skip: user_id=%d — no stores included in filters", user_id)
|
||||
return
|
||||
|
||||
# Collect all Evotor products and groups across included stores
|
||||
evo_products: list[dict] = []
|
||||
groups_by_id: dict[str, dict] = {}
|
||||
|
||||
for store_id in store_ids:
|
||||
raw_groups = await _evo_fetch_groups(evo_token, store_id)
|
||||
for g in raw_groups:
|
||||
gid = g.get("uuid") or g.get("id")
|
||||
if gid:
|
||||
groups_by_id[gid] = g
|
||||
|
||||
raw_products = await _evo_fetch_products(evo_token, store_id)
|
||||
for p in raw_products:
|
||||
pid = p.get("uuid") or p.get("id")
|
||||
gid = p.get("parentUuid") or p.get("parent_id")
|
||||
if not _is_group_included(gid, filters):
|
||||
continue
|
||||
if not _is_product_included(pid, filters):
|
||||
continue
|
||||
evo_products.append(p)
|
||||
|
||||
# Build evo product lookup by normalized name
|
||||
# {normalized_name: product_dict}
|
||||
evo_by_name: dict[str, dict] = {}
|
||||
for p in evo_products:
|
||||
raw_name = (p.get("name") or "").strip()
|
||||
norm = _normalize_name(raw_name)
|
||||
evo_by_name[norm] = p
|
||||
|
||||
# Fetch VK state
|
||||
vk_products = await _vk_get_products(vk_token, owner_id)
|
||||
vk_albums = await _vk_get_albums(vk_token, owner_id)
|
||||
vk_album_by_title: dict[str, dict] = {a["title"]: a for a in vk_albums}
|
||||
|
||||
# Ensure albums exist for all included groups
|
||||
for gid, group in groups_by_id.items():
|
||||
if not _is_group_included(gid, filters):
|
||||
continue
|
||||
title = group.get("name", "")
|
||||
if title and title not in vk_album_by_title:
|
||||
new_album_id = await _vk_create_album(vk_token, owner_id, title)
|
||||
if new_album_id:
|
||||
vk_album_by_title[title] = {"id": new_album_id, "title": title}
|
||||
logger.info("Created VK album '%s' for user_id=%d", title, user_id)
|
||||
|
||||
# Build VK product lookup by normalized name
|
||||
# {normalized_name: [vk_item, ...]}
|
||||
vk_by_name: dict[str, list[dict]] = {}
|
||||
for item in vk_products:
|
||||
norm = _normalize_name(item.get("title", ""))
|
||||
vk_by_name.setdefault(norm, []).append(item)
|
||||
|
||||
# --- UPDATE / CREATE ---
|
||||
for norm_name, evo_p in evo_by_name.items():
|
||||
evo_id = evo_p.get("uuid") or evo_p.get("id")
|
||||
raw_name = (evo_p.get("name") or "").strip()
|
||||
name_for_vk = _normalize_name(raw_name)
|
||||
measure = evo_p.get("measureName") or evo_p.get("measure_name")
|
||||
raw_price = evo_p.get("price") or 0
|
||||
price, price_info = _calc_price(raw_price, measure)
|
||||
allow_to_sell = evo_p.get("allowToSell") if evo_p.get("allowToSell") is not None else evo_p.get("allow_to_sell")
|
||||
stock_amount = VK_STOCK_AMOUNT if allow_to_sell else 0
|
||||
extra_desc = evo_p.get("description") or ""
|
||||
description = _build_description(raw_name, price_info, extra_desc).strip()
|
||||
|
||||
gid = evo_p.get("parentUuid") or evo_p.get("parent_id")
|
||||
group_name = groups_by_id.get(gid, {}).get("name") if gid else None
|
||||
album = vk_album_by_title.get(group_name) if group_name else None
|
||||
album_id = album["id"] if album else None
|
||||
|
||||
if norm_name in vk_by_name:
|
||||
# Update existing (use first match)
|
||||
vk_item = vk_by_name[norm_name][0]
|
||||
vk_id = vk_item["id"]
|
||||
orig_price = vk_item.get("price", {}).get("amount", 0)
|
||||
orig_price_int = int(orig_price) if orig_price else 0
|
||||
orig_desc = (vk_item.get("description") or "").strip()
|
||||
orig_stock = vk_item.get("stock_amount", 0)
|
||||
|
||||
price_changed = price != orig_price_int
|
||||
desc_changed = description != orig_desc
|
||||
stock_changed = stock_amount != orig_stock
|
||||
|
||||
if price_changed or desc_changed or stock_changed:
|
||||
logger.info(
|
||||
"Updating VK product '%s' user_id=%d (price=%s desc=%s stock=%s)",
|
||||
name_for_vk, user_id, price_changed, desc_changed, stock_changed,
|
||||
)
|
||||
await _vk_edit_product(
|
||||
vk_token, owner_id, vk_id, name_for_vk, description, price, stock_amount,
|
||||
)
|
||||
_stamp_synced(db, user_id, evo_id, now)
|
||||
else:
|
||||
# Create new (only if allow_to_sell)
|
||||
if not allow_to_sell:
|
||||
continue
|
||||
photo_id = await _vk_upload_photo(vk_token, vk_group_id, photo_path)
|
||||
if not photo_id:
|
||||
logger.error("Skipping product '%s' — photo upload failed", name_for_vk)
|
||||
continue
|
||||
logger.info("Creating VK product '%s' user_id=%d", name_for_vk, user_id)
|
||||
created = await _vk_create_product(
|
||||
vk_token, owner_id, name_for_vk, description,
|
||||
price, stock_amount, photo_id, album_id,
|
||||
)
|
||||
if created:
|
||||
_stamp_synced(db, user_id, evo_id, now)
|
||||
|
||||
# --- DELETE products in VK that are no longer in Evo ---
|
||||
for norm_name, vk_items in vk_by_name.items():
|
||||
if norm_name in evo_by_name:
|
||||
# Delete duplicates (keep first)
|
||||
for dup in vk_items[1:]:
|
||||
logger.info("Deleting duplicate VK product '%s' id=%d user_id=%d",
|
||||
norm_name, dup["id"], user_id)
|
||||
await _vk_delete_product(vk_token, owner_id, dup["id"])
|
||||
else:
|
||||
# Delete all — product removed from Evo
|
||||
for item in vk_items:
|
||||
logger.info("Deleting removed product '%s' id=%d user_id=%d",
|
||||
norm_name, item["id"], user_id)
|
||||
await _vk_delete_product(vk_token, owner_id, item["id"])
|
||||
|
||||
logger.info("Sync complete: user_id=%d", user_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def run_sync() -> None:
|
||||
from web.config import settings
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
configs = db.query(SyncConfig).filter(
|
||||
SyncConfig.is_enabled == True,
|
||||
SyncConfig.confirmed_at != None,
|
||||
).all()
|
||||
|
||||
for config in configs:
|
||||
user_id = config.user_id
|
||||
evo = db.query(EvotorConnection).filter(
|
||||
EvotorConnection.user_id == user_id
|
||||
).first()
|
||||
vk = db.query(VkConnection).filter(
|
||||
VkConnection.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not evo or not vk:
|
||||
continue
|
||||
if not evo.access_token or not vk.access_token:
|
||||
continue
|
||||
if not vk.vk_user_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
await sync_user(
|
||||
user_id=user_id,
|
||||
evo_token=evo.access_token,
|
||||
vk_token=vk.access_token,
|
||||
vk_group_id=vk.vk_user_id,
|
||||
filters=config.filters,
|
||||
photo_path=settings.VK_DEFAULT_PHOTO_PATH,
|
||||
db=db,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Sync failed for user_id=%d", user_id)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error in sync runner")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def sync_loop(interval: int) -> None:
|
||||
while True:
|
||||
await run_sync()
|
||||
await asyncio.sleep(interval)
|
||||
@@ -1,97 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<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/bootstrap@5.3.3/dist/css/bootstrap.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>
|
||||
<nav class="navbar navbar-expand-lg bg-white border-bottom border-2 brand-border">
|
||||
<div class="container">
|
||||
<a href="/" class="navbar-brand brand-logo">ЭВОСИНК</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if user %}
|
||||
<li class="nav-item">
|
||||
<a href="/connections" class="nav-link">Подключения</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/catalog" class="nav-link">Каталог</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/sync" class="nav-link">Синхронизация</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/logout" class="nav-link text-muted">Выход</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a href="/login" class="nav-link">Вход</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/register" class="nav-link">Регистрация</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container py-4">
|
||||
{% if errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for error in errors %}
|
||||
<p class="mb-1">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if success %}
|
||||
<div class="alert alert-success">
|
||||
<p class="mb-0">{{ 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/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Подтверждение email — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||
<div class="card shadow-sm mt-5 text-center">
|
||||
<div class="card-body p-5">
|
||||
<i class="bi bi-envelope-check display-4 text-primary mb-3"></i>
|
||||
<h1 class="h4 mb-3">Подтвердите ваш email</h1>
|
||||
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,67 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Подключения — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h1 class="h4 mb-0">Подключения</h1>
|
||||
<a href="/connections/add" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i>Добавить
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if connections %}
|
||||
<div class="row g-3">
|
||||
{% for conn in connections %}
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="bi {{ conn.icon }} fs-2 me-3 text-secondary"></i>
|
||||
<div>
|
||||
<h5 class="mb-0">{{ conn.name }}</h5>
|
||||
{% if conn.details %}
|
||||
<small class="text-muted">{{ conn.details }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
{% if conn.is_online %}
|
||||
<i class="bi bi-circle-fill text-success fs-5" title="Онлайн"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-circle-fill text-danger fs-5" title="Офлайн"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ conn.configure_url }}" class="btn btn-outline-primary btn-sm flex-fill">Настроить</a>
|
||||
<form method="post" action="/connections/delete?type={{ conn.type }}">
|
||||
<button type="submit"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
onclick="return confirm('Вы уверены, что хотите отключить {{ conn.name }}?')">
|
||||
Отключить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-muted small">
|
||||
{% if conn.last_checked_at %}
|
||||
Проверено: {{ conn.last_checked_at.strftime("%d.%m.%Y %H:%M") }}
|
||||
{% else %}
|
||||
Статус ещё не проверялся
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-plug fs-1 mb-3 d-block"></i>
|
||||
<p class="mb-3">Нет подключённых сервисов</p>
|
||||
<a href="/connections/add" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>Добавить подключение
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,39 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Добавить подключение — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<a href="/connections" class="text-muted me-3"><i class="bi bi-arrow-left fs-5"></i></a>
|
||||
<h1 class="h4 mb-0">Добавить подключение</h1>
|
||||
</div>
|
||||
|
||||
{% if available %}
|
||||
<div class="row g-3">
|
||||
{% for svc in available %}
|
||||
<div class="col-sm-6 col-lg-4">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="bi {{ svc.icon }} fs-2 me-3 text-secondary"></i>
|
||||
<h5 class="mb-0">{{ svc.name }}</h5>
|
||||
</div>
|
||||
<p class="text-muted small flex-grow-1">{{ svc.description }}</p>
|
||||
<a href="{{ svc.connect_url }}" class="btn btn-primary btn-sm mt-auto">
|
||||
Подключить <i class="bi bi-arrow-right ms-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-check-circle fs-1 mb-3 d-block text-success"></i>
|
||||
<p class="mb-3">Все доступные сервисы подключены</p>
|
||||
<a href="/connections" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,17 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Email подтвержден — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||
<div class="card shadow-sm mt-5 text-center">
|
||||
<div class="card-body p-5">
|
||||
<i class="bi bi-check-circle display-4 text-success mb-3"></i>
|
||||
<h1 class="h4 mb-3">Email подтвержден!</h1>
|
||||
<p class="text-muted">Ваш email успешно подтвержден. Теперь вы можете войти в систему.</p>
|
||||
<a href="/login" class="btn btn-primary mt-2">Войти</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,27 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Забыли пароль — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="card-title h4 mb-2">Забыли пароль?</h1>
|
||||
<p class="text-muted small mb-4">Введите email, указанный при регистрации.</p>
|
||||
<form method="post" action="/forgot-password">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Отправить ссылку для сброса</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-3 text-center small">
|
||||
<a href="/login">Вернуться ко входу</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,32 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Вход — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="card-title h4 mb-4">Вход</h1>
|
||||
<form method="post" action="/login">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control"
|
||||
value="{{ form.email if form else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Пароль</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Войти</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-3 text-center small">
|
||||
<a href="/forgot-password">Забыли пароль?</a><br>
|
||||
<a href="/register">Зарегистрироваться</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,18 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }} — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||
<div class="card shadow-sm mt-5 text-center">
|
||||
<div class="card-body p-5">
|
||||
<h1 class="h4 mb-3">{{ title }}</h1>
|
||||
<p class="text-muted">{{ message }}</p>
|
||||
{% if link %}
|
||||
<a href="{{ link }}" class="btn btn-primary mt-2">{{ link_text }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,46 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Изменить пароль — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header">
|
||||
<h1 class="h5 mb-0"><i class="bi bi-key me-2"></i>Изменить пароль</h1>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{% if success %}
|
||||
<div class="alert alert-success">{{ success }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for error in errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/profile/change-password">
|
||||
<div class="mb-3">
|
||||
<label for="current_password" class="form-label">Текущий пароль</label>
|
||||
<input type="password" id="current_password" name="current_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Новый пароль</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="password_confirm" class="form-label">Подтвердить пароль</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Изменить пароль</button>
|
||||
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,41 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||
<div class="card shadow-sm mt-4 border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h1 class="h5 mb-0"><i class="bi bi-trash me-2"></i>Удалить аккаунт</h1>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Внимание!</strong> Это действие необратимо. Все ваши данные будут удалены.
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for error in errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/profile/delete">
|
||||
<div class="mb-4">
|
||||
<label for="password" class="form-label">Введите пароль для подтверждения</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash me-1"></i>Удалить мой аккаунт
|
||||
</button>
|
||||
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,55 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-7 col-lg-6">
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header">
|
||||
<h1 class="h5 mb-0"><i class="bi bi-pencil me-2"></i>Редактировать профиль</h1>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{% if success %}
|
||||
<div class="alert alert-success">{{ success }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for error in errors %}
|
||||
<div>{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/profile/edit">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label for="first_name" class="form-label">Имя</label>
|
||||
<input type="text" id="first_name" name="first_name" class="form-control"
|
||||
value="{{ form.first_name if form else user.first_name }}" required>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label for="last_name" class="form-label">Фамилия</label>
|
||||
<input type="text" id="last_name" name="last_name" class="form-control"
|
||||
value="{{ form.last_name if form else user.last_name }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted">Email</label>
|
||||
<input type="email" class="form-control" value="{{ user.email }}" disabled>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="phone" class="form-label">Телефон</label>
|
||||
<input type="tel" id="phone" name="phone" class="form-control"
|
||||
value="{{ form.phone if form else user.phone }}" required>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,52 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Регистрация — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-7 col-lg-6">
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="card-title h4 mb-4">Регистрация</h1>
|
||||
<form method="post" action="/register">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label for="first_name" class="form-label">Имя</label>
|
||||
<input type="text" id="first_name" name="first_name" class="form-control"
|
||||
value="{{ form.first_name if form else '' }}">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label for="last_name" class="form-label">Фамилия</label>
|
||||
<input type="text" id="last_name" name="last_name" class="form-control"
|
||||
value="{{ form.last_name if form else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email <span class="text-danger">*</span></label>
|
||||
<input type="email" id="email" name="email" class="form-control"
|
||||
value="{{ form.email if form else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="phone" class="form-label">Телефон <span class="text-danger">*</span></label>
|
||||
<input type="tel" id="phone" name="phone" class="form-control"
|
||||
value="{{ form.phone if form else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Пароль <span class="text-danger">*</span></label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="password_confirm" class="form-label">Подтверждение пароля <span class="text-danger">*</span></label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Зарегистрироваться</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-3 text-center small">
|
||||
<a href="/login">Уже есть аккаунт? Войти</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,27 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Новый пароль — ЭВОСИНК{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="card-title h4 mb-4">Новый пароль</h1>
|
||||
<form method="post" action="/reset-password?token={{ token }}">
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Новый пароль</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="password_confirm" class="form-label">Подтверждение пароля</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Сменить пароль</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,6 +0,0 @@
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from web.config import settings
|
||||
|
||||
templates = Jinja2Templates(directory="web/templates")
|
||||
templates.env.globals["jivosite_widget_id"] = settings.JIVOSITE_WIDGET_ID
|
||||
15
web/tsconfig.json
Normal file
15
web/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user