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:
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
18
web/migrations/versions/0008_sync_config_price_postfix.py
Normal file
18
web/migrations/versions/0008_sync_config_price_postfix.py
Normal 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")
|
||||
@@ -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
75
web/routes/sync.py
Normal 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)
|
||||
@@ -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
35
web/templates/sync.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user