feat: release v1.8.0 — connections dashboard, VK OAuth, sync config, catalog browser

- Connections dashboard with add/remove flow and background health checks
- VK OAuth connection with profile info and health monitoring
- Sync configuration page with master toggle and filter summary
- Catalog browser with store/group/product tables, filter management, CSV export
- Alembic migrations for all new tables
- run/read_config.py for shell sync script DB integration
- CHANGELOG.md updated for v1.8.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-03-06 16:08:19 +03:00
parent cfc7229daf
commit 9aeef73b10
20 changed files with 2010 additions and 85 deletions

115
web/evotor_api.py Normal file
View File

@@ -0,0 +1,115 @@
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,
)
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,
)
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()

View File

@@ -10,7 +10,7 @@ from web.auth import get_current_user
from web.config import settings
from web.health_checker import health_check_loop
from web.models import User
from web.routes import auth, profile, reset, evotor, vk
from web.routes import auth, profile, reset, evotor, vk, sync, catalog
from web.routes import connections
@@ -36,6 +36,8 @@ 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("/")

View File

@@ -0,0 +1,48 @@
"""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')

View File

@@ -0,0 +1,70 @@
"""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')

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint, Numeric, Index
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -23,6 +23,10 @@ class User(Base):
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):
@@ -56,3 +60,96 @@ class VkConnection(Base):
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)
__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")

309
web/routes/catalog.py Normal file
View File

@@ -0,0 +1,309 @@
import csv
import io
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
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")
templates = Jinja2Templates(directory="web/templates")
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}"},
)

View File

@@ -10,6 +10,43 @@ from web.models import User, EvotorConnection, VkConnection
router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
SERVICE_TYPES = [
{
"type": "evotor",
"name": "Эвотор",
"icon": "bi-shop",
"description": "Подключите кассу Эвотор для синхронизации каталога товаров.",
"configure_url": "/evotor",
"connect_url": "/evotor/connect",
},
{
"type": "vk",
"name": "ВКонтакте",
"icon": "bi-bag",
"description": "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.",
"configure_url": "/vk",
"connect_url": "/vk/connect",
},
]
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(
@@ -23,31 +60,67 @@ def connections_page(
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
connections = [
{
"name": "Эвотор",
"icon": "bi-shop",
"connected": evotor is not None,
"is_online": evotor.is_online if evotor else False,
"last_checked_at": evotor.last_checked_at if evotor else None,
"details": evotor.store_name if evotor else None,
"connect_url": "/evotor/connect",
"disconnect_url": "/evotor/disconnect",
},
{
"name": "ВКонтакте",
"icon": "bi-chat-dots",
"connected": vk is not None,
"is_online": vk.is_online if vk else False,
"last_checked_at": vk.last_checked_at if vk else None,
"details": f"{vk.first_name} {vk.last_name}".strip() if vk and vk.first_name else None,
"connect_url": "/vk",
"disconnect_url": "/vk/disconnect",
},
]
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": connections,
"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)

102
web/routes/sync.py Normal file
View File

@@ -0,0 +1,102 @@
from datetime import datetime
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
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")
templates = Jinja2Templates(directory="web/templates")
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)

View File

@@ -21,6 +21,12 @@
<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>

View File

@@ -0,0 +1,108 @@
{% extends "base.html" %}
{% block title %}Группы — {{ store.name }} — EvoSync{% 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>
<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">
<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>
<p>Группы не найдены в этом магазине.</p>
<a href="/catalog/products?store_id={{ store.evotor_id }}" class="btn btn-outline-primary btn-sm">
Посмотреть все товары магазина
</a>
</div>
{% else %}
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Кол-во товаров</th>
<th>Фильтр</th>
<th></th>
</tr>
</thead>
<tbody>
{% for group in groups %}
{% set mode = filter_map.get(group.evotor_id) %}
<tr>
<td>{{ group.name }}</td>
<td class="text-muted">{{ product_counts.get(group.evotor_id, 0) }}</td>
<td>
{% if mode == "include" %}
<span class="badge bg-success">✓ Включено</span>
{% elif mode == "exclude" %}
<span class="badge bg-danger">✗ Исключено</span>
{% else %}
<span class="badge bg-light text-muted border">— Нет правила</span>
{% endif %}
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<a href="/catalog/products?store_id={{ store.evotor_id }}&group_id={{ group.evotor_id }}"
class="btn btn-outline-secondary btn-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">
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="group">
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ group.name }}">
<input type="hidden" name="filter_mode" value="include">
<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">✓ Включить в синхронизацию</button>
</form>
</li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="group">
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ group.name }}">
<input type="hidden" name="filter_mode" value="exclude">
<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">✗ Исключить из синхронизации</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="group">
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ group.name }}">
<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>
</form>
</li>
</ul>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}Товары — EvoSync{% 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>
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 mb-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">
<i class="bi bi-download me-1"></i>Экспорт CSV
</a>
</div>
{% 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>
<p>Товары не найдены.</p>
</div>
{% else %}
<div class="table-responsive">
<table class="table table-striped table-hover align-middle small">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Артикул</th>
<th>Цена</th>
<th>Кол-во</th>
<th>Ед. изм.</th>
<th>В продаже</th>
<th>Фильтр</th>
<th></th>
</tr>
</thead>
<tbody>
{% for product in products %}
{% set mode = filter_map.get(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 class="text-muted">{{ product.measure_name or "—" }}</td>
<td>
{% if product.allow_to_sell is none %}
<span class="text-muted"></span>
{% elif product.allow_to_sell %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<i class="bi bi-x-circle-fill text-danger"></i>
{% endif %}
</td>
<td>
{% if mode == "include" %}
<span class="badge bg-success">✓ Включено</span>
{% elif mode == "exclude" %}
<span class="badge bg-danger">✗ Исключено</span>
{% else %}
<span class="badge bg-light text-muted border">— Нет правила</span>
{% endif %}
</td>
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="product">
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ product.name }}">
<input type="hidden" name="filter_mode" value="include">
<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">✓ Включить в синхронизацию</button>
</form>
</li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="product">
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ product.name }}">
<input type="hidden" name="filter_mode" value="exclude">
<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">✗ Исключить из синхронизации</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="product">
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ product.name }}">
<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>
</form>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,113 @@
{% extends "base.html" %}
{% block title %}Каталог — EvoSync{% 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 gap-2">
{% if evotor %}
<form method="post" action="/catalog/refresh">
<button type="submit" class="btn btn-outline-secondary btn-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">
<i class="bi bi-download me-1"></i>Экспорт CSV
</a>
{% endif %}
</div>
</div>
{% if not evotor %}
<div class="alert alert-warning d-flex align-items-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>
<p>Магазины не найдены в вашем аккаунте Эвотор.</p>
</div>
{% else %}
{% if fetched_at %}
<p class="text-muted small mb-3">Последнее обновление: {{ fetched_at.strftime("%d.%m.%Y %H:%M") }}</p>
{% endif %}
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Адрес</th>
<th>Фильтр</th>
<th></th>
</tr>
</thead>
<tbody>
{% for store in stores %}
{% set mode = filter_map.get(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>
{% elif mode == "exclude" %}
<span class="badge bg-danger">✗ Исключено</span>
{% else %}
<span class="badge bg-light text-muted border">— Нет правила</span>
{% endif %}
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<a href="/catalog/groups?store_id={{ store.evotor_id }}"
class="btn btn-outline-secondary btn-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">
<i class="bi bi-funnel"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="store">
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ store.name }}">
<input type="hidden" name="filter_mode" value="include">
<input type="hidden" name="redirect_to" value="/catalog">
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
</form>
</li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="store">
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
<input type="hidden" name="entity_name" value="{{ store.name }}">
<input type="hidden" name="filter_mode" value="exclude">
<input type="hidden" name="redirect_to" value="/catalog">
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
</form>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<form method="post" action="/catalog/filter">
<input type="hidden" name="entity_type" value="store">
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
<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>
</form>
</li>
</ul>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -2,8 +2,14 @@
{% block title %}Подключения — EvoSync{% endblock %}
{% block content %}
<h1 class="h4 mb-4">Подключения</h1>
<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">
@@ -18,9 +24,7 @@
{% endif %}
</div>
<div class="ms-auto">
{% if not conn.connected %}
<i class="bi bi-circle text-secondary fs-5" title="Не подключено"></i>
{% elif conn.is_online %}
{% 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>
@@ -28,18 +32,17 @@
</div>
</div>
<div class="d-grid gap-2">
{% if conn.connected %}
<a href="{{ conn.connect_url }}" class="btn btn-outline-primary btn-sm">Переподключить</a>
<form method="post" action="{{ conn.disconnect_url }}">
<button type="submit" class="btn btn-outline-danger btn-sm w-100">Отключить</button>
</form>
{% else %}
<a href="{{ conn.connect_url }}" class="btn btn-primary btn-sm">Подключить</a>
{% endif %}
<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>
{% if conn.connected %}
<div class="card-footer text-muted small">
{% if conn.last_checked_at %}
Проверено: {{ conn.last_checked_at.strftime("%d.%m.%Y %H:%M") }}
@@ -47,9 +50,18 @@
Статус ещё не проверялся
{% endif %}
</div>
{% endif %}
</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 %}

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Добавить подключение — EvoSync{% 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 %}

110
web/templates/sync.html Normal file
View File

@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}Синхронизация — EvoSync{% endblock %}
{% block content %}
<h1 class="h4 mb-4">Синхронизация</h1>
{% if not evotor %}
<div class="alert alert-warning d-flex align-items-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">
<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="card-body">
<h5 class="card-title 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>
{% elif status == "paused" %}
<span class="badge bg-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>
{% else %}
<span class="badge bg-light text-dark border 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") }}
</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">
<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 %}>
<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">
<i class="bi bi-check-lg me-1"></i>Подтвердить и запустить
</button>
</form>
{% endif %}
</div>
{% if config.is_enabled and summary.total == 0 %}
<p class="text-muted small mt-2 mb-0">
<i class="bi bi-info-circle me-1"></i>Настройте фильтры, чтобы подтвердить запуск.
</p>
{% endif %}
</div>
</div>
</div>
{# ── Filters card ── #}
<div class="col-12 col-md-6">
<div class="card shadow-sm h-100">
<div class="card-body">
<h5 class="card-title mb-3">Фильтры</h5>
{% if summary.total > 0 %}
<ul class="list-unstyled mb-3">
{% if summary.stores > 0 %}
<li class="text-muted small"><i class="bi bi-shop me-1"></i>Магазины: {{ summary.stores }} правил</li>
{% endif %}
{% if summary.groups > 0 %}
<li class="text-muted small"><i class="bi bi-folder me-1"></i>Группы: {{ summary.groups }} правил</li>
{% endif %}
{% if summary.products > 0 %}
<li class="text-muted small"><i class="bi bi-box me-1"></i>Товары: {{ summary.products }} правил</li>
{% endif %}
</ul>
{% 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 %}>
<i class="bi bi-sliders me-1"></i>Настроить фильтры
</a>
</div>
</div>
</div>
</div>
{% endblock %}