feat: /sync settings page with price multiplier and description postfix

Adds price_multiplier and description_postfix to SyncConfig. The sync
page at /sync lets users configure them. vk_sync reads these per-user
settings and applies the multiplier to price and appends the postfix
as "(postfix)" to the VK product description.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-05-12 23:04:23 +03:00
parent fb3b6e2327
commit e169a91146
6 changed files with 142 additions and 6 deletions

View File

@@ -39,6 +39,7 @@ from web.routes.catalog import router as catalog_router # noqa: E402
from web.routes.connections import router as connections_router # noqa: E402
from web.routes.vk_catalog import router as vk_catalog_router # noqa: E402
from web.routes.logs import router as logs_router # noqa: E402
from web.routes.sync import router as sync_router # noqa: E402
app.include_router(auth_router)
app.include_router(reset_router)
@@ -50,6 +51,7 @@ app.include_router(catalog_router)
app.include_router(connections_router)
app.include_router(vk_catalog_router)
app.include_router(logs_router)
app.include_router(sync_router)
# ── Catalog redirect ─────────────────────────────────────────────────────────

View File

@@ -0,0 +1,18 @@
"""Add price_multiplier and description_postfix to sync_configs."""
revision = "0008"
down_revision = "0007"
branch_labels = None
depends_on = None
import sqlalchemy as sa
from alembic import op
def upgrade():
op.add_column("sync_configs", sa.Column("price_multiplier", sa.Numeric(10, 4), nullable=False, server_default="1.0"))
op.add_column("sync_configs", sa.Column("description_postfix", sa.String(255), nullable=True))
def downgrade():
op.drop_column("sync_configs", "description_postfix")
op.drop_column("sync_configs", "price_multiplier")

View File

@@ -58,6 +58,8 @@ class SyncConfig(Base):
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
is_enabled = Column(Boolean, nullable=False, default=False)
confirmed_at = Column(DateTime, nullable=True)
price_multiplier = Column(Numeric(10, 4), nullable=False, default=1.0)
description_postfix = Column(String(255), nullable=True)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())

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

@@ -0,0 +1,75 @@
"""Sync settings page."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from web.auth.session import get_current_user
from web.config import settings
from web.database import get_db
from web.models.connections import SyncConfig
from web.templates_env import templates
router = APIRouter()
def _render(request: Request, ctx: dict):
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(ctx.pop("request"), "sync.html", ctx)
def _now():
return datetime.now(timezone.utc).replace(tzinfo=None)
@router.get("/sync")
async def sync_get(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
config = db.query(SyncConfig).filter_by(user_id=user.id).first()
return _render(request, {
"user": user,
"config": config,
"saved": request.query_params.get("saved"),
})
@router.post("/sync/settings")
async def sync_settings_post(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
raw_multiplier = str(form.get("price_multiplier", "1")).strip()
postfix = str(form.get("description_postfix", "")).strip() or None
try:
multiplier = float(raw_multiplier)
if multiplier <= 0:
raise ValueError
except ValueError:
multiplier = 1.0
config = db.query(SyncConfig).filter_by(user_id=user.id).first()
if config:
config.price_multiplier = multiplier
config.description_postfix = postfix
else:
config = SyncConfig(
user_id=user.id,
is_enabled=False,
price_multiplier=multiplier,
description_postfix=postfix,
)
db.add(config)
db.commit()
return RedirectResponse("/sync?saved=1", 303)

View File

@@ -148,10 +148,13 @@ def _sync_product(
album_id: str,
vk_group_id: str,
token: str,
sync_config=None,
) -> None:
name = _name_for_vk(product.name)
price_rubles = _calc_price(product.price)
desc = product.name
multiplier = float(sync_config.price_multiplier) if sync_config and sync_config.price_multiplier else 1.0
price_rubles = _calc_price(product.price) * multiplier
postfix = (sync_config.description_postfix or "").strip() if sync_config else ""
desc = f"{product.name} ({postfix})" if postfix else product.name
stock = settings.VK_STOCK_AMOUNT if product.allow_to_sell else 0
owner_id = f"-{vk_group_id}"
now = _now()
@@ -257,11 +260,11 @@ def _sync_product(
logger.info("user=%s created VK product '%s' id=%s", user_id, name, vk_item_id)
def _sync_product_list(db, user_id, products, album_id, vk_group_id, token, results, owned_ids):
def _sync_product_list(db, user_id, products, album_id, vk_group_id, token, results, owned_ids, sync_config=None):
for product in products:
was_new = product.vk_product_id is None
try:
_sync_product(db, user_id, product, album_id, vk_group_id, token)
_sync_product(db, user_id, product, album_id, vk_group_id, token, sync_config)
if product.vk_product_id:
owned_ids.add(product.vk_product_id)
if product.synced_at:
@@ -318,6 +321,7 @@ def _sync_user(db, user_id: int, token: str, vk_group_id: str) -> dict:
results = {"created": 0, "updated": 0, "skipped": 0, "deleted": 0, "errors": 0}
owned_ids: set[str] = set() # VK product IDs that Evotor owns this run
sync_config = db.query(SyncConfig).filter_by(user_id=user_id).first()
enabled_stores = _enabled_store_ids(db, user_id)
stores = db.query(CachedStore).filter_by(user_id=user_id).all()
@@ -348,7 +352,7 @@ def _sync_user(db, user_id: int, token: str, vk_group_id: str) -> dict:
products = db.query(CachedProduct).filter_by(
user_id=user_id, store_evotor_id=store.evotor_id, group_evotor_id=group.evotor_id,
).all()
_sync_product_list(db, user_id, products, album_id, vk_group_id, token, results, owned_ids)
_sync_product_list(db, user_id, products, album_id, vk_group_id, token, results, owned_ids, sync_config)
# Ungrouped products → "Без категории" album
ungrouped = db.query(CachedProduct).filter_by(
@@ -361,7 +365,7 @@ def _sync_user(db, user_id: int, token: str, vk_group_id: str) -> dict:
logger.error("user=%s ensure_album failed for 'Без категории': %s", user_id, e)
fallback_album_id = None
if fallback_album_id:
_sync_product_list(db, user_id, ungrouped, fallback_album_id, vk_group_id, token, results, owned_ids)
_sync_product_list(db, user_id, ungrouped, fallback_album_id, vk_group_id, token, results, owned_ids, sync_config)
# Delete VK products not owned by any Evotor product
_delete_orphans(db, user_id, vk_group_id, owned_ids, token, results)

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

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Синхронизация — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex justify-between align-center mb-3">
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-arrow-repeat me-2"></i>Синхронизация</h1>
</div>
{% if saved %}
<div role="alert" class="alert alert-success"><p>Настройки сохранены.</p></div>
{% endif %}
<article class="card">
<h2 style="font-size:1.1rem; margin-bottom:1.25rem;">Настройки цены и описания</h2>
<form method="post" action="/sync/settings">
<div class="grid" style="grid-template-columns: 1fr 1fr; gap:1.5rem;">
<label>
Множитель цены
<input type="number" name="price_multiplier" step="0.0001" min="0.0001"
value="{{ config.price_multiplier if config else '1' }}"
placeholder="1">
<small class="text-muted">Цена из Эвотор умножается на это значение перед отправкой в ВК. По умолчанию: 1.</small>
</label>
<label>
Постфикс описания
<input type="text" name="description_postfix"
value="{{ config.description_postfix if config and config.description_postfix else '' }}"
placeholder="цена за 10г.">
<small class="text-muted">Если заполнено, добавляется к описанию: «Название (постфикс)». Оставьте пустым, чтобы не добавлять.</small>
</label>
</div>
<button type="submit" style="margin-top:1rem;">Сохранить</button>
</form>
</article>
{% endblock %}