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.connections import router as connections_router # noqa: E402
|
||||||
from web.routes.vk_catalog import router as vk_catalog_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.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(auth_router)
|
||||||
app.include_router(reset_router)
|
app.include_router(reset_router)
|
||||||
@@ -50,6 +51,7 @@ app.include_router(catalog_router)
|
|||||||
app.include_router(connections_router)
|
app.include_router(connections_router)
|
||||||
app.include_router(vk_catalog_router)
|
app.include_router(vk_catalog_router)
|
||||||
app.include_router(logs_router)
|
app.include_router(logs_router)
|
||||||
|
app.include_router(sync_router)
|
||||||
|
|
||||||
|
|
||||||
# ── Catalog redirect ─────────────────────────────────────────────────────────
|
# ── 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)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
is_enabled = Column(Boolean, nullable=False, default=False)
|
is_enabled = Column(Boolean, nullable=False, default=False)
|
||||||
confirmed_at = Column(DateTime, nullable=True)
|
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())
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=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,
|
album_id: str,
|
||||||
vk_group_id: str,
|
vk_group_id: str,
|
||||||
token: str,
|
token: str,
|
||||||
|
sync_config=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
name = _name_for_vk(product.name)
|
name = _name_for_vk(product.name)
|
||||||
price_rubles = _calc_price(product.price)
|
multiplier = float(sync_config.price_multiplier) if sync_config and sync_config.price_multiplier else 1.0
|
||||||
desc = product.name
|
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
|
stock = settings.VK_STOCK_AMOUNT if product.allow_to_sell else 0
|
||||||
owner_id = f"-{vk_group_id}"
|
owner_id = f"-{vk_group_id}"
|
||||||
now = _now()
|
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)
|
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:
|
for product in products:
|
||||||
was_new = product.vk_product_id is None
|
was_new = product.vk_product_id is None
|
||||||
try:
|
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:
|
if product.vk_product_id:
|
||||||
owned_ids.add(product.vk_product_id)
|
owned_ids.add(product.vk_product_id)
|
||||||
if product.synced_at:
|
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}
|
results = {"created": 0, "updated": 0, "skipped": 0, "deleted": 0, "errors": 0}
|
||||||
owned_ids: set[str] = set() # VK product IDs that Evotor owns this run
|
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)
|
enabled_stores = _enabled_store_ids(db, user_id)
|
||||||
|
|
||||||
stores = db.query(CachedStore).filter_by(user_id=user_id).all()
|
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(
|
products = db.query(CachedProduct).filter_by(
|
||||||
user_id=user_id, store_evotor_id=store.evotor_id, group_evotor_id=group.evotor_id,
|
user_id=user_id, store_evotor_id=store.evotor_id, group_evotor_id=group.evotor_id,
|
||||||
).all()
|
).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 products → "Без категории" album
|
||||||
ungrouped = db.query(CachedProduct).filter_by(
|
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)
|
logger.error("user=%s ensure_album failed for 'Без категории': %s", user_id, e)
|
||||||
fallback_album_id = None
|
fallback_album_id = None
|
||||||
if fallback_album_id:
|
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 VK products not owned by any Evotor product
|
||||||
_delete_orphans(db, user_id, vk_group_id, owned_ids, token, results)
|
_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