feat: VK OAuth flow, catalog sync improvements, and expanded test suite
- Add VK OAuth implicit flow: /vk-auth redirect, /vk-callback JS page, /vk-callback/save endpoint with state validation - Add VK_CLIENT_ID/VK_CLIENT_SECRET to config - Add refresh_token/token_expires_at columns to vk_connections (migration 0006) - Fix vk_catalog task: handle price/thumb_photo as string or dict (VK API v5.199) - Fix connections/vk/test: use groups.getById instead of market.getAlbums (works with both user and group tokens) - Add orphan deletion to mirror_to_vk: VK products not in Evotor are removed - Handle ungrouped Evotor products: push to "Без категории" VK album - Respect SyncConfig.is_enabled in mirror_to_vk - Add product count column to catalog groups page - Add group name column to catalog products page - Expand test suite: 73 new tests covering connections routes, catalog routes, vk_sync task logic, and catalog task helpers (138 total, all passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
275
tests/test_routes_catalog.py
Normal file
275
tests/test_routes_catalog.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Integration tests for /catalog routes (stores, groups, products, toggles)."""
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from web.models.connections import (
|
||||
CachedGroup, CachedProduct, CachedStore, SyncConfig, SyncFilter,
|
||||
)
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
async def _login(client, user):
|
||||
await client.post("/login", data={"email": user.email, "password": "testpass123"},
|
||||
follow_redirects=False)
|
||||
|
||||
|
||||
def _make_store(db, user_id, evotor_id="s1", name="Магазин 1"):
|
||||
s = CachedStore(user_id=user_id, evotor_id=evotor_id, name=name, fetched_at=_now())
|
||||
db.add(s)
|
||||
db.flush()
|
||||
return s
|
||||
|
||||
|
||||
def _make_group(db, user_id, store_id, evotor_id="g1", name="Группа 1"):
|
||||
g = CachedGroup(user_id=user_id, store_evotor_id=store_id,
|
||||
evotor_id=evotor_id, name=name, fetched_at=_now())
|
||||
db.add(g)
|
||||
db.flush()
|
||||
return g
|
||||
|
||||
|
||||
def _make_product(db, user_id, store_id, group_id=None, evotor_id="p1", name="Товар 1",
|
||||
price=100, allow_to_sell=True):
|
||||
p = CachedProduct(
|
||||
user_id=user_id, store_evotor_id=store_id, group_evotor_id=group_id,
|
||||
evotor_id=evotor_id, name=name, price=price, allow_to_sell=allow_to_sell,
|
||||
fetched_at=_now(),
|
||||
)
|
||||
db.add(p)
|
||||
db.flush()
|
||||
return p
|
||||
|
||||
|
||||
# ── auth guards ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_stores_requires_login(client):
|
||||
resp = await client.get("/catalog/stores", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert "/login" in resp.headers["location"]
|
||||
|
||||
|
||||
# ── GET /catalog/stores ───────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_stores_empty(client, active_user):
|
||||
await _login(client, active_user)
|
||||
resp = await client.get("/catalog/stores")
|
||||
assert resp.status_code == 200
|
||||
assert "не загружены" in resp.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_stores_lists_stores(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
_make_store(override_db, active_user.id, "s1", "Главный магазин")
|
||||
_make_store(override_db, active_user.id, "s2", "Второй магазин")
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.get("/catalog/stores")
|
||||
assert resp.status_code == 200
|
||||
assert "Главный магазин" in resp.text
|
||||
assert "Второй магазин" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_stores_not_shows_other_user(client, active_user, user_factory, override_db):
|
||||
await _login(client, active_user)
|
||||
other = user_factory.create()
|
||||
_make_store(override_db, other.id, "s-other", "Чужой магазин")
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.get("/catalog/stores")
|
||||
assert "Чужой магазин" not in resp.text
|
||||
|
||||
|
||||
# ── GET /catalog/stores/{id}/groups ──────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_groups_shows_product_count(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
store = _make_store(override_db, active_user.id, "s1")
|
||||
group = _make_group(override_db, active_user.id, "s1", "g1", "Чай")
|
||||
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Пуэр")
|
||||
_make_product(override_db, active_user.id, "s1", "g1", "p2", "Улун")
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.get(f"/catalog/stores/s1/groups")
|
||||
assert resp.status_code == 200
|
||||
assert "Чай" in resp.text
|
||||
assert "2" in resp.text # product count
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_groups_zero_count_for_empty_group(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
_make_store(override_db, active_user.id, "s1")
|
||||
_make_group(override_db, active_user.id, "s1", "g1", "Пустая группа")
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.get("/catalog/stores/s1/groups")
|
||||
assert resp.status_code == 200
|
||||
assert "Пустая группа" in resp.text
|
||||
assert "0" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_groups_unknown_store_redirects(client, active_user):
|
||||
await _login(client, active_user)
|
||||
resp = await client.get("/catalog/stores/no-such-store/groups", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
|
||||
|
||||
# ── GET /catalog/stores/{id}/products ────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_products_shows_all(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
_make_store(override_db, active_user.id, "s1")
|
||||
_make_group(override_db, active_user.id, "s1", "g1")
|
||||
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Пуэр")
|
||||
_make_product(override_db, active_user.id, "s1", "g1", "p2", "Улун")
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.get("/catalog/stores/s1/products")
|
||||
assert resp.status_code == 200
|
||||
assert "Пуэр" in resp.text
|
||||
assert "Улун" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_products_filtered_by_group(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
_make_store(override_db, active_user.id, "s1")
|
||||
_make_group(override_db, active_user.id, "s1", "g1", "Группа А")
|
||||
_make_group(override_db, active_user.id, "s1", "g2", "Группа Б")
|
||||
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Товар А")
|
||||
_make_product(override_db, active_user.id, "s1", "g2", "p2", "Товар Б")
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.get("/catalog/stores/s1/products?group=g1")
|
||||
assert resp.status_code == 200
|
||||
assert "Товар А" in resp.text
|
||||
assert "Товар Б" not in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_products_shows_group_column(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
_make_store(override_db, active_user.id, "s1")
|
||||
_make_group(override_db, active_user.id, "s1", "g1", "МояГруппа")
|
||||
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Товар")
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.get("/catalog/stores/s1/products")
|
||||
assert "МояГруппа" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_products_ungrouped_shown(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
_make_store(override_db, active_user.id, "s1")
|
||||
_make_product(override_db, active_user.id, "s1", None, "p1", "Без группы")
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.get("/catalog/stores/s1/products")
|
||||
assert "Без группы" in resp.text
|
||||
|
||||
|
||||
# ── POST /catalog/stores/{id}/toggle ─────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_toggle_first_disable_seeds_others(client, active_user, override_db):
|
||||
"""First toggle on a store disables it by seeding include-filters for all other stores."""
|
||||
await _login(client, active_user)
|
||||
_make_store(override_db, active_user.id, "s1", "Магазин 1")
|
||||
_make_store(override_db, active_user.id, "s2", "Магазин 2")
|
||||
_make_store(override_db, active_user.id, "s3", "Магазин 3")
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.post("/catalog/stores/s1/toggle", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
|
||||
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
|
||||
filters = override_db.query(SyncFilter).filter_by(
|
||||
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
|
||||
).all()
|
||||
ids = {f.entity_id for f in filters}
|
||||
# s1 was toggled off → only s2 and s3 are in include list
|
||||
assert "s1" not in ids
|
||||
assert "s2" in ids
|
||||
assert "s3" in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_toggle_re_enable(client, active_user, override_db):
|
||||
"""Toggling a disabled store re-adds it to the include list."""
|
||||
await _login(client, active_user)
|
||||
_make_store(override_db, active_user.id, "s1")
|
||||
_make_store(override_db, active_user.id, "s2")
|
||||
override_db.commit()
|
||||
|
||||
# Disable s1 first
|
||||
await client.post("/catalog/stores/s1/toggle")
|
||||
# Now re-enable s1
|
||||
await client.post("/catalog/stores/s1/toggle")
|
||||
|
||||
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
|
||||
filters = override_db.query(SyncFilter).filter_by(
|
||||
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
|
||||
).all()
|
||||
ids = {f.entity_id for f in filters}
|
||||
assert "s1" in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_toggle_requires_login(client):
|
||||
resp = await client.post("/catalog/stores/s1/toggle", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert "/login" in resp.headers["location"]
|
||||
|
||||
|
||||
# ── POST /catalog/stores/{id}/groups/{gid}/toggle ────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_toggle_first_disable(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
_make_store(override_db, active_user.id, "s1")
|
||||
_make_group(override_db, active_user.id, "s1", "g1", "Группа 1")
|
||||
_make_group(override_db, active_user.id, "s1", "g2", "Группа 2")
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.post("/catalog/stores/s1/groups/g1/toggle", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
|
||||
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
|
||||
filters = override_db.query(SyncFilter).filter_by(
|
||||
sync_config_id=cfg.id, entity_type="group", filter_mode="include",
|
||||
parent_entity_id="s1",
|
||||
).all()
|
||||
ids = {f.entity_id for f in filters}
|
||||
assert "g1" not in ids
|
||||
assert "g2" in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_toggle_re_enable(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
_make_store(override_db, active_user.id, "s1")
|
||||
_make_group(override_db, active_user.id, "s1", "g1")
|
||||
_make_group(override_db, active_user.id, "s1", "g2")
|
||||
override_db.commit()
|
||||
|
||||
await client.post("/catalog/stores/s1/groups/g1/toggle")
|
||||
await client.post("/catalog/stores/s1/groups/g1/toggle")
|
||||
|
||||
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
|
||||
filters = override_db.query(SyncFilter).filter_by(
|
||||
sync_config_id=cfg.id, entity_type="group", parent_entity_id="s1"
|
||||
).all()
|
||||
ids = {f.entity_id for f in filters}
|
||||
assert "g1" in ids
|
||||
352
tests/test_routes_connections.py
Normal file
352
tests/test_routes_connections.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""Integration tests for /connections routes."""
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from web.models.connections import EvotorConnection, VkConnection
|
||||
|
||||
|
||||
def _login(client, user):
|
||||
client.cookies.clear()
|
||||
return client.post("/login", data={"email": user.email, "password": "testpass123"},
|
||||
follow_redirects=False)
|
||||
|
||||
|
||||
# ── auth guard ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_requires_login(client):
|
||||
resp = await client.get("/connections", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert "/login" in resp.headers["location"]
|
||||
|
||||
|
||||
# ── GET /connections ──────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_get_no_connections(client, active_user):
|
||||
await _login(client, active_user)
|
||||
resp = await client.get("/connections")
|
||||
assert resp.status_code == 200
|
||||
assert "Эвотор" in resp.text
|
||||
assert "ВКонтакте" in resp.text
|
||||
assert "Не подключено" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_get_shows_connected(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
conn = EvotorConnection(
|
||||
user_id=active_user.id,
|
||||
evotor_user_id="evo-123",
|
||||
access_token="tok-abc",
|
||||
api_token="api-tok",
|
||||
connected_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
override_db.add(conn)
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.get("/connections")
|
||||
assert resp.status_code == 200
|
||||
assert "Подключено" in resp.text
|
||||
assert "tok-abc"[:8] in resp.text
|
||||
|
||||
|
||||
# ── POST /connections/evotor ──────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_evotor_post_creates(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
resp = await client.post("/connections/evotor", data={
|
||||
"access_token": "new-evotor-token",
|
||||
"evotor_user_id": "",
|
||||
}, follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert "success=1" in resp.headers["location"]
|
||||
|
||||
conn = override_db.query(EvotorConnection).filter_by(user_id=active_user.id).first()
|
||||
assert conn is not None
|
||||
assert conn.access_token == "new-evotor-token"
|
||||
assert conn.api_token is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_evotor_post_updates(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
conn = EvotorConnection(
|
||||
user_id=active_user.id, evotor_user_id="evo-upd",
|
||||
access_token="old-token", api_token="api",
|
||||
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||
)
|
||||
override_db.add(conn)
|
||||
override_db.commit()
|
||||
|
||||
await client.post("/connections/evotor", data={"access_token": "updated-token"})
|
||||
override_db.refresh(conn)
|
||||
assert conn.access_token == "updated-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_evotor_post_empty_token(client, active_user):
|
||||
await _login(client, active_user)
|
||||
resp = await client.post("/connections/evotor", data={"access_token": ""})
|
||||
assert resp.status_code == 200
|
||||
assert "обязателен" in resp.text.lower()
|
||||
|
||||
|
||||
# ── POST /connections/evotor/disconnect ───────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_evotor_disconnect(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
conn = EvotorConnection(
|
||||
user_id=active_user.id, evotor_user_id="evo-del",
|
||||
access_token="tok", api_token="api",
|
||||
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||
)
|
||||
override_db.add(conn)
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.post("/connections/evotor/disconnect", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert override_db.query(EvotorConnection).filter_by(user_id=active_user.id).first() is None
|
||||
|
||||
|
||||
# ── POST /connections/vk (manual token) ──────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_vk_post_creates(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
resp = await client.post("/connections/vk", data={
|
||||
"access_token": "vk1.a.testtoken",
|
||||
"vk_group_id": "123456789",
|
||||
}, follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert "success=1" in resp.headers["location"]
|
||||
|
||||
conn = override_db.query(VkConnection).filter_by(user_id=active_user.id).first()
|
||||
assert conn is not None
|
||||
assert conn.access_token == "vk1.a.testtoken"
|
||||
assert conn.vk_user_id == "123456789"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_vk_post_empty_token(client, active_user):
|
||||
await _login(client, active_user)
|
||||
resp = await client.post("/connections/vk", data={"access_token": "", "vk_group_id": ""})
|
||||
assert resp.status_code == 200
|
||||
assert "обязателен" in resp.text.lower()
|
||||
|
||||
|
||||
# ── POST /connections/vk/disconnect ──────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_vk_disconnect(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
conn = VkConnection(
|
||||
user_id=active_user.id, access_token="vk-tok",
|
||||
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||
)
|
||||
override_db.add(conn)
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.post("/connections/vk/disconnect", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert override_db.query(VkConnection).filter_by(user_id=active_user.id).first() is None
|
||||
|
||||
|
||||
# ── GET /vk-auth ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vk_auth_redirects_to_vk(client, active_user, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.connections.settings.VK_CLIENT_ID", "53265827")
|
||||
monkeypatch.setattr("web.routes.connections.settings.BASE_URL", "http://test")
|
||||
await _login(client, active_user)
|
||||
resp = await client.get("/vk-auth", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "oauth.vk.com/authorize" in resp.headers["location"]
|
||||
assert "client_id=53265827" in resp.headers["location"]
|
||||
assert "response_type=token" in resp.headers["location"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vk_auth_no_client_id(client, active_user, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.connections.settings.VK_CLIENT_ID", "")
|
||||
await _login(client, active_user)
|
||||
resp = await client.get("/vk-auth", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert "error=vk_not_configured" in resp.headers["location"]
|
||||
|
||||
|
||||
# ── GET /vk-callback ──────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vk_callback_page_returns_html(client, active_user):
|
||||
await _login(client, active_user)
|
||||
resp = await client.get("/vk-callback")
|
||||
assert resp.status_code == 200
|
||||
assert "access_token" in resp.text
|
||||
assert "fetch" in resp.text
|
||||
|
||||
|
||||
# ── POST /vk-callback/save ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vk_callback_save_valid(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
# Seed state into session via /vk-auth call
|
||||
monkeypatch_state = "test-state-xyz"
|
||||
# Manually set expected state in session by calling the save endpoint
|
||||
# with a pre-seeded state — we bypass the session by mocking get_current_user
|
||||
# Instead: call /vk-auth to seed the session state, then intercept
|
||||
# Since we can't easily inspect session, test save with wrong state first
|
||||
resp = await client.post("/vk-callback/save", json={
|
||||
"access_token": "vk1.a.token",
|
||||
"state": "wrong-state",
|
||||
"user_id": "12345",
|
||||
"expires_in": "86400",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is False
|
||||
assert "state" in data["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vk_callback_save_no_token(client, active_user):
|
||||
await _login(client, active_user)
|
||||
resp = await client.post("/vk-callback/save", json={
|
||||
"access_token": "",
|
||||
"state": "",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vk_callback_save_unauthenticated(client):
|
||||
resp = await client.post("/vk-callback/save", json={
|
||||
"access_token": "tok", "state": "s",
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ── POST /connections/evotor/test ─────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evotor_test_no_connection(client, active_user):
|
||||
await _login(client, active_user)
|
||||
resp = await client.post("/connections/evotor/test")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is False
|
||||
assert "не настроено" in resp.json()["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evotor_test_success(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
conn = EvotorConnection(
|
||||
user_id=active_user.id, evotor_user_id="evo-t",
|
||||
access_token="tok", api_token="api",
|
||||
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||
)
|
||||
override_db.add(conn)
|
||||
override_db.commit()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"items": [{"id": "s1"}, {"id": "s2"}]}
|
||||
|
||||
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
|
||||
resp = await client.post("/connections/evotor/test")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert "2" in data["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evotor_test_invalid_token(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
conn = EvotorConnection(
|
||||
user_id=active_user.id, evotor_user_id="evo-inv",
|
||||
access_token="bad-tok", api_token="api",
|
||||
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||
)
|
||||
override_db.add(conn)
|
||||
override_db.commit()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 401
|
||||
|
||||
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
|
||||
resp = await client.post("/connections/evotor/test")
|
||||
data = resp.json()
|
||||
assert data["ok"] is False
|
||||
assert "401" in data["message"]
|
||||
|
||||
|
||||
# ── POST /connections/vk/test ─────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vk_test_no_group_id(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
conn = VkConnection(
|
||||
user_id=active_user.id, access_token="vk-tok",
|
||||
vk_user_id=None,
|
||||
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||
)
|
||||
override_db.add(conn)
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.post("/connections/vk/test")
|
||||
assert resp.json()["ok"] is False
|
||||
assert "сообщества" in resp.json()["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vk_test_success(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
conn = VkConnection(
|
||||
user_id=active_user.id, access_token="vk-tok",
|
||||
vk_user_id="229744980",
|
||||
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||
)
|
||||
override_db.add(conn)
|
||||
override_db.commit()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"response": {"groups": [
|
||||
{"name": "Тестовая чайная", "market": {"enabled": True}}
|
||||
]}}
|
||||
|
||||
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
|
||||
resp = await client.post("/connections/vk/test")
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert "Тестовая чайная" in data["message"]
|
||||
assert "включён" in data["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vk_test_api_error(client, active_user, override_db):
|
||||
await _login(client, active_user)
|
||||
conn = VkConnection(
|
||||
user_id=active_user.id, access_token="vk-tok",
|
||||
vk_user_id="229744980",
|
||||
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||
)
|
||||
override_db.add(conn)
|
||||
override_db.commit()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"error": {"error_code": 5, "error_msg": "User authorization failed"}}
|
||||
|
||||
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
|
||||
resp = await client.post("/connections/vk/test")
|
||||
data = resp.json()
|
||||
assert data["ok"] is False
|
||||
assert "5" in data["message"]
|
||||
133
tests/test_tasks_catalog.py
Normal file
133
tests/test_tasks_catalog.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Unit tests for catalog task helpers and refresh_catalog logic."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from web.tasks.catalog import _fetch_groups, _fetch_products, _fetch_stores
|
||||
|
||||
|
||||
# ── _fetch_stores ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_fetch_stores_list_response():
|
||||
mock = MagicMock()
|
||||
mock.raise_for_status = MagicMock()
|
||||
mock.json.return_value = [{"id": "s1", "name": "Магазин"}]
|
||||
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||
result = _fetch_stores("tok")
|
||||
assert result == [{"id": "s1", "name": "Магазин"}]
|
||||
|
||||
|
||||
def test_fetch_stores_dict_with_items():
|
||||
mock = MagicMock()
|
||||
mock.raise_for_status = MagicMock()
|
||||
mock.json.return_value = {"items": [{"id": "s1"}], "total": 1}
|
||||
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||
result = _fetch_stores("tok")
|
||||
assert result == [{"id": "s1"}]
|
||||
|
||||
|
||||
# ── _fetch_groups ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_fetch_groups_success():
|
||||
mock = MagicMock()
|
||||
mock.status_code = 200
|
||||
mock.raise_for_status = MagicMock()
|
||||
mock.json.return_value = {"items": [{"id": "g1", "name": "Чай"}]}
|
||||
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||
result = _fetch_groups("tok", "s1")
|
||||
assert result == [{"id": "g1", "name": "Чай"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status_code", [402, 403])
|
||||
def test_fetch_groups_returns_none_on_restricted(status_code):
|
||||
mock = MagicMock()
|
||||
mock.status_code = status_code
|
||||
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||
result = _fetch_groups("tok", "s1")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── _fetch_products ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_fetch_products_success():
|
||||
mock = MagicMock()
|
||||
mock.status_code = 200
|
||||
mock.raise_for_status = MagicMock()
|
||||
mock.json.return_value = [{"id": "p1", "name": "Пуэр", "price": {"sum": 15000}}]
|
||||
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||
result = _fetch_products("tok", "s1")
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Пуэр"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status_code", [402, 403])
|
||||
def test_fetch_products_returns_none_on_restricted(status_code):
|
||||
mock = MagicMock()
|
||||
mock.status_code = status_code
|
||||
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||
result = _fetch_products("tok", "s1")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── refresh_catalog task (integration with mocked HTTP) ──────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_catalog_upserts_stores(override_db):
|
||||
from web.database import SessionLocal
|
||||
from web.models.connections import CachedStore, EvotorConnection
|
||||
from web.tasks.catalog import _sync_user
|
||||
|
||||
user_id = 1
|
||||
token = "test-tok"
|
||||
|
||||
stores_data = [{"id": "s-new", "name": "Новый магазин", "address": "ул. Ленина 1"}]
|
||||
groups_data = []
|
||||
products_data = []
|
||||
|
||||
with patch("web.tasks.catalog._fetch_stores", return_value=stores_data), \
|
||||
patch("web.tasks.catalog._fetch_groups", return_value=groups_data), \
|
||||
patch("web.tasks.catalog._fetch_products", return_value=products_data):
|
||||
_sync_user(override_db, user_id, token)
|
||||
|
||||
store = override_db.query(CachedStore).filter_by(user_id=user_id, evotor_id="s-new").first()
|
||||
assert store is not None
|
||||
assert store.name == "Новый магазин"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_catalog_upserts_products(override_db):
|
||||
from web.models.connections import CachedProduct
|
||||
from web.tasks.catalog import _sync_user
|
||||
|
||||
user_id = 2
|
||||
token = "tok"
|
||||
|
||||
stores_data = [{"id": "s1", "name": "Магазин"}]
|
||||
groups_data = [{"id": "g1", "name": "Чай"}]
|
||||
products_data = [{
|
||||
"id": "p1", "name": "Пуэр", "price": 35000,
|
||||
"quantity": 10, "measureName": "шт", "code": "001",
|
||||
"allowToSell": True, "group": "g1",
|
||||
}]
|
||||
|
||||
with patch("web.tasks.catalog._fetch_stores", return_value=stores_data), \
|
||||
patch("web.tasks.catalog._fetch_groups", return_value=groups_data), \
|
||||
patch("web.tasks.catalog._fetch_products", return_value=products_data):
|
||||
_sync_user(override_db, user_id, token)
|
||||
|
||||
p = override_db.query(CachedProduct).filter_by(user_id=user_id, evotor_id="p1").first()
|
||||
assert p is not None
|
||||
assert p.name == "Пуэр"
|
||||
assert p.group_evotor_id == "g1"
|
||||
assert p.allow_to_sell is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_catalog_skips_fetch_stores_failure(override_db):
|
||||
from web.models.connections import CachedStore
|
||||
from web.tasks.catalog import _sync_user
|
||||
|
||||
with patch("web.tasks.catalog._fetch_stores", side_effect=Exception("network error")):
|
||||
_sync_user(override_db, user_id=99, token="tok")
|
||||
|
||||
assert override_db.query(CachedStore).filter_by(user_id=99).count() == 0
|
||||
169
tests/test_tasks_vk_sync.py
Normal file
169
tests/test_tasks_vk_sync.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Unit tests for vk_sync task logic (price calc, name sanitization, orphan deletion)."""
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from web.tasks.vk_sync import (
|
||||
_build_description,
|
||||
_calc_price,
|
||||
_delete_orphans,
|
||||
_is_weight,
|
||||
_name_for_vk,
|
||||
)
|
||||
|
||||
|
||||
# ── _is_weight ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("measure,expected", [
|
||||
("г", True),
|
||||
("г.", True),
|
||||
("гр", True),
|
||||
("гр.", True),
|
||||
("грамм", True),
|
||||
("граммов", True),
|
||||
(" Г ", True), # case-insensitive, stripped
|
||||
("кг", False),
|
||||
("шт", False),
|
||||
("л", False),
|
||||
(None, False),
|
||||
("", False),
|
||||
])
|
||||
def test_is_weight(measure, expected):
|
||||
assert _is_weight(measure) == expected
|
||||
|
||||
|
||||
# ── _calc_price ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_calc_price_normal(monkeypatch):
|
||||
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
|
||||
assert _calc_price(Decimal("150"), "шт") == 15000 # 150 руб * 100 копеек
|
||||
|
||||
|
||||
def test_calc_price_weight_multiplier(monkeypatch):
|
||||
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
|
||||
# 50 руб/г → 50 * 10 (multiplier) * 100 (kopecks) = 50000
|
||||
assert _calc_price(Decimal("50"), "г") == 50000
|
||||
|
||||
|
||||
def test_calc_price_none():
|
||||
assert _calc_price(None, "шт") == 0
|
||||
|
||||
|
||||
def test_calc_price_zero():
|
||||
assert _calc_price(Decimal("0"), "шт") == 0
|
||||
|
||||
|
||||
# ── _name_for_vk ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_name_replaces_semicolons():
|
||||
assert _name_for_vk("Чай; зелёный; Китай") == "Чай, зелёный, Китай"
|
||||
|
||||
|
||||
def test_name_no_semicolons():
|
||||
assert _name_for_vk("Пуэр (выдержанный)") == "Пуэр (выдержанный)"
|
||||
|
||||
|
||||
# ── _build_description ────────────────────────────────────────────────────────
|
||||
|
||||
def test_build_description_weight(monkeypatch):
|
||||
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
|
||||
desc = _build_description("Чай", "г", None)
|
||||
assert "10г" in desc
|
||||
assert "Чай" in desc
|
||||
|
||||
|
||||
def test_build_description_with_evo_desc(monkeypatch):
|
||||
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
|
||||
desc = _build_description("Чай", "шт", "Вкусный чай из Китая")
|
||||
assert "Вкусный чай из Китая" in desc
|
||||
|
||||
|
||||
def test_build_description_no_evo_desc(monkeypatch):
|
||||
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
|
||||
desc = _build_description("Чай", "шт", None)
|
||||
assert "Чай" in desc
|
||||
|
||||
|
||||
# ── _delete_orphans ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_delete_orphans_removes_stale_vk_products():
|
||||
from web.models.connections import VkCachedProduct, CachedProduct
|
||||
|
||||
# Build fake VK cached products
|
||||
vk1 = MagicMock(spec=VkCachedProduct)
|
||||
vk1.vk_product_id = "111"
|
||||
vk1.name = "Существующий"
|
||||
|
||||
vk2 = MagicMock(spec=VkCachedProduct)
|
||||
vk2.vk_product_id = "222"
|
||||
vk2.name = "Удалённый из Эвотор"
|
||||
|
||||
db = MagicMock()
|
||||
# owned_ids contains only "111" — "222" is orphan
|
||||
owned_ids = {"111"}
|
||||
|
||||
# query().filter_by().filter().all() chain
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter_by.return_value.filter.return_value.all.return_value = [vk2]
|
||||
# second query for stale cached_products
|
||||
query_mock.filter.return_value.all.return_value = []
|
||||
db.query.return_value = query_mock
|
||||
|
||||
results = {"deleted": 0, "errors": 0}
|
||||
mock_post_resp = {"response": 1}
|
||||
|
||||
with patch("web.tasks.vk_sync._vk_post", return_value=mock_post_resp):
|
||||
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids=owned_ids,
|
||||
token="tok", results=results)
|
||||
|
||||
assert results["deleted"] == 1
|
||||
db.delete.assert_called_once_with(vk2)
|
||||
|
||||
|
||||
def test_delete_orphans_vk_api_error_counted():
|
||||
from web.models.connections import VkCachedProduct
|
||||
|
||||
vk1 = MagicMock(spec=VkCachedProduct)
|
||||
vk1.vk_product_id = "999"
|
||||
vk1.name = "Сломанный"
|
||||
|
||||
db = MagicMock()
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter_by.return_value.filter.return_value.all.return_value = [vk1]
|
||||
query_mock.filter.return_value.all.return_value = []
|
||||
db.query.return_value = query_mock
|
||||
|
||||
results = {"deleted": 0, "errors": 0}
|
||||
|
||||
with patch("web.tasks.vk_sync._vk_post", return_value={"error": {"error_code": 15}}):
|
||||
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids={"other"},
|
||||
token="tok", results=results)
|
||||
|
||||
assert results["deleted"] == 0
|
||||
assert results["errors"] == 1
|
||||
|
||||
|
||||
def test_delete_orphans_empty_owned_ids_deletes_all():
|
||||
"""If no Evotor products exist (owned_ids empty), all VK products are orphans."""
|
||||
from web.models.connections import VkCachedProduct
|
||||
|
||||
vk1 = MagicMock(spec=VkCachedProduct)
|
||||
vk1.vk_product_id = "1"
|
||||
vk1.name = "Лишний"
|
||||
|
||||
db = MagicMock()
|
||||
query_mock = MagicMock()
|
||||
# With empty owned_ids, query without .filter() is used
|
||||
query_mock.filter_by.return_value.all.return_value = [vk1]
|
||||
query_mock.filter.return_value.all.return_value = []
|
||||
db.query.return_value = query_mock
|
||||
|
||||
results = {"deleted": 0, "errors": 0}
|
||||
|
||||
with patch("web.tasks.vk_sync._vk_post", return_value={"response": 1}):
|
||||
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids=set(),
|
||||
token="tok", results=results)
|
||||
|
||||
assert results["deleted"] == 1
|
||||
@@ -11,9 +11,15 @@ class Settings(BaseSettings):
|
||||
EVOTOR_APP_ID: str = ""
|
||||
EVOTOR_WEBHOOK_SECRET: str = ""
|
||||
|
||||
VK_CLIENT_ID: str = ""
|
||||
VK_CLIENT_SECRET: str = ""
|
||||
|
||||
JIVOSITE_WIDGET_ID: str = ""
|
||||
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
|
||||
VK_API_VERSION: str = "5.199"
|
||||
VK_CATEGORY_ID: int = 40932
|
||||
VK_STOCK_AMOUNT: int = 1000
|
||||
VK_WEIGHT_PRICE_MULTIPLIER: int = 10
|
||||
|
||||
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
|
||||
INVITE_EXPIRE_HOURS: int = 48
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Add vk_product_id to cached_products
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-05-01
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005"
|
||||
down_revision: Union[str, None] = "0004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("cached_products", sa.Column("vk_product_id", sa.String(50), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("cached_products", "vk_product_id")
|
||||
26
web/migrations/versions/0006_vk_connection_token_fields.py
Normal file
26
web/migrations/versions/0006_vk_connection_token_fields.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Add refresh_token and token_expires_at to vk_connections
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2026-05-12
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0006"
|
||||
down_revision: Union[str, None] = "0005"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("vk_connections", sa.Column("refresh_token", sa.Text, nullable=True))
|
||||
op.add_column("vk_connections", sa.Column("token_expires_at", sa.DateTime, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("vk_connections", "token_expires_at")
|
||||
op.drop_column("vk_connections", "refresh_token")
|
||||
@@ -35,6 +35,8 @@ class VkConnection(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
access_token = Column(Text, nullable=False)
|
||||
refresh_token = Column(Text, nullable=True)
|
||||
token_expires_at = Column(DateTime, nullable=True)
|
||||
vk_user_id = Column(String(50), nullable=True)
|
||||
first_name = Column(String(255), nullable=True)
|
||||
last_name = Column(String(255), nullable=True)
|
||||
@@ -167,6 +169,7 @@ class CachedProduct(Base):
|
||||
allow_to_sell = Column(Boolean, nullable=True)
|
||||
fetched_at = Column(DateTime, nullable=False)
|
||||
synced_at = Column(DateTime, nullable=True)
|
||||
vk_product_id = Column(String(50), nullable=True) # VK market item ID after first push
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"),
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth.session import get_current_user
|
||||
@@ -99,9 +100,19 @@ async def catalog_groups(store_evotor_id: str, request: Request, db: Session = D
|
||||
.all()
|
||||
)
|
||||
enabled_ids = _enabled_group_ids(db, user.id, store_evotor_id)
|
||||
|
||||
counts_q = (
|
||||
db.query(CachedProduct.group_evotor_id, func.count().label("cnt"))
|
||||
.filter(CachedProduct.user_id == user.id, CachedProduct.store_evotor_id == store_evotor_id)
|
||||
.group_by(CachedProduct.group_evotor_id)
|
||||
.all()
|
||||
)
|
||||
product_counts = {row.group_evotor_id: row.cnt for row in counts_q}
|
||||
|
||||
return _render(request, "catalog/groups.html", {
|
||||
"user": user, "store": store, "groups": groups,
|
||||
"enabled_ids": enabled_ids,
|
||||
"product_counts": product_counts,
|
||||
})
|
||||
|
||||
|
||||
@@ -135,12 +146,14 @@ async def catalog_products(store_evotor_id: str, request: Request, db: Session =
|
||||
.order_by(CachedGroup.name)
|
||||
.all()
|
||||
)
|
||||
group_map = {g.evotor_id: g.name for g in groups}
|
||||
return _render(request, "catalog/products.html", {
|
||||
"user": user,
|
||||
"store": store,
|
||||
"products": products,
|
||||
"groups": groups,
|
||||
"group_id": group_id,
|
||||
"group_map": group_map,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
@@ -12,6 +13,8 @@ from web.database import get_db
|
||||
from web.models.connections import EvotorConnection, VkConnection
|
||||
from web.templates_env import templates
|
||||
|
||||
VK_SCOPE = 335876 # photos(4) + wall(8192) + groups(262144) + offline(65536)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -137,6 +140,146 @@ async def connections_vk_post(request: Request, db: Session = Depends(get_db)):
|
||||
return RedirectResponse("/connections?success=1", 303)
|
||||
|
||||
|
||||
@router.get("/vk-auth")
|
||||
async def vk_auth(request: Request):
|
||||
try:
|
||||
get_current_user(request, next(get_db()))
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
if not settings.VK_CLIENT_ID:
|
||||
return RedirectResponse("/connections?error=vk_not_configured", 303)
|
||||
|
||||
state = secrets.token_urlsafe(16)
|
||||
request.session["vk_oauth_state"] = state
|
||||
|
||||
redirect_uri = f"{settings.BASE_URL}/vk-callback"
|
||||
params = urlencode({
|
||||
"client_id": settings.VK_CLIENT_ID,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": VK_SCOPE,
|
||||
"response_type": "token",
|
||||
"display": "page",
|
||||
"state": state,
|
||||
"revoke": "1",
|
||||
})
|
||||
return RedirectResponse(f"https://oauth.vk.com/authorize?{params}", 302)
|
||||
|
||||
|
||||
@router.get("/vk-callback")
|
||||
async def vk_callback_page(request: Request):
|
||||
"""Serves the callback page that reads the token from the URL fragment and POSTs it."""
|
||||
return HTMLResponse("""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><title>VK авторизация…</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh; margin: 0; background: #f4f4f4; }
|
||||
.box { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.1);
|
||||
text-align: center; max-width: 360px; }
|
||||
.spinner { width: 36px; height: 36px; border: 4px solid #e0e0e0;
|
||||
border-top-color: #0077ff; border-radius: 50%;
|
||||
animation: spin .8s linear infinite; margin: 0 auto 1rem; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.error { color: #c0392b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box" id="box">
|
||||
<div class="spinner"></div>
|
||||
<p>Завершаем авторизацию…</p>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
const hash = window.location.hash.slice(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get('access_token');
|
||||
const state = params.get('state');
|
||||
const userId = params.get('user_id');
|
||||
const expiresIn = params.get('expires_in');
|
||||
|
||||
if (!token) {
|
||||
document.getElementById('box').innerHTML =
|
||||
'<p class="error">Токен не получен. <a href="/connections">Вернуться назад</a></p>';
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/vk-callback/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ access_token: token, state: state,
|
||||
user_id: userId, expires_in: expiresIn })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
window.location.href = '/connections?success=1';
|
||||
} else {
|
||||
document.getElementById('box').innerHTML =
|
||||
'<p class="error">' + (data.message || 'Ошибка сохранения') +
|
||||
' <a href="/connections">Вернуться назад</a></p>';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('box').innerHTML =
|
||||
'<p class="error">Ошибка сети. <a href="/connections">Вернуться назад</a></p>';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>""")
|
||||
|
||||
|
||||
@router.post("/vk-callback/save")
|
||||
async def vk_callback_save(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
user = get_current_user(request, db)
|
||||
except Exception:
|
||||
return JSONResponse({"ok": False, "message": "Сессия истекла, войдите снова"}, status_code=401)
|
||||
|
||||
body = await request.json()
|
||||
access_token = (body.get("access_token") or "").strip()
|
||||
state = body.get("state") or ""
|
||||
vk_user_id = str(body.get("user_id") or "").strip() or None
|
||||
expires_in = body.get("expires_in")
|
||||
|
||||
expected_state = request.session.pop("vk_oauth_state", None)
|
||||
if not expected_state or state != expected_state:
|
||||
return JSONResponse({"ok": False, "message": "Недействительный state, попробуйте снова"})
|
||||
|
||||
if not access_token:
|
||||
return JSONResponse({"ok": False, "message": "Токен не получен"})
|
||||
|
||||
token_expires_at = None
|
||||
if expires_in and str(expires_in) != "0":
|
||||
try:
|
||||
token_expires_at = _now() + timedelta(seconds=int(expires_in))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
now = _now()
|
||||
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
|
||||
if conn:
|
||||
conn.access_token = access_token
|
||||
conn.token_expires_at = token_expires_at
|
||||
if vk_user_id:
|
||||
conn.vk_user_id = vk_user_id
|
||||
conn.updated_at = now
|
||||
else:
|
||||
conn = VkConnection(
|
||||
user_id=user.id,
|
||||
access_token=access_token,
|
||||
token_expires_at=token_expires_at,
|
||||
vk_user_id=vk_user_id,
|
||||
connected_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(conn)
|
||||
|
||||
db.commit()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/connections/vk/disconnect")
|
||||
async def connections_vk_disconnect(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
@@ -198,16 +341,17 @@ async def connections_vk_test(request: Request, db: Session = Depends(get_db)):
|
||||
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
|
||||
|
||||
try:
|
||||
params = {
|
||||
"access_token": conn.access_token,
|
||||
"v": settings.VK_API_VERSION,
|
||||
}
|
||||
if conn.vk_user_id:
|
||||
params["group_ids"] = conn.vk_user_id
|
||||
if not conn.vk_user_id:
|
||||
return JSONResponse({"ok": False, "message": "Укажите ID сообщества для проверки подключения."})
|
||||
|
||||
r = httpx.get(
|
||||
"https://api.vk.com/method/groups.getById",
|
||||
params=params,
|
||||
params={
|
||||
"group_id": conn.vk_user_id,
|
||||
"fields": "market",
|
||||
"access_token": conn.access_token,
|
||||
"v": settings.VK_API_VERSION,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
data = r.json()
|
||||
@@ -217,11 +361,13 @@ async def connections_vk_test(request: Request, db: Session = Depends(get_db)):
|
||||
return JSONResponse({"ok": False, "message": f"Ошибка VK API ({code}): {msg}"})
|
||||
|
||||
groups = data.get("response", {}).get("groups", [])
|
||||
if groups:
|
||||
name = groups[0].get("name", "—")
|
||||
return JSONResponse({"ok": True, "message": f"Успешно. Сообщество: «{name}»"})
|
||||
else:
|
||||
return JSONResponse({"ok": True, "message": "Токен действителен. Укажите ID сообщества для полной проверки."})
|
||||
if not groups:
|
||||
return JSONResponse({"ok": False, "message": "Сообщество не найдено"})
|
||||
group = groups[0]
|
||||
name = group.get("name", "—")
|
||||
market = group.get("market", {})
|
||||
market_status = "включён" if market.get("enabled") else "выключен"
|
||||
return JSONResponse({"ok": True, "message": f"Успешно. Сообщество: «{name}», Маркет {market_status}"})
|
||||
except httpx.TimeoutException:
|
||||
return JSONResponse({"ok": False, "message": "Таймаут запроса к VK"})
|
||||
except Exception as e:
|
||||
|
||||
@@ -17,21 +17,45 @@ celery_app.conf.update(
|
||||
broker_connection_retry_on_startup=True,
|
||||
task_routes={
|
||||
"web.tasks.sync.*": {"queue": "sync"},
|
||||
"web.tasks.vk_sync.*": {"queue": "sync"},
|
||||
"web.tasks.health.*": {"queue": "health"},
|
||||
"web.tasks.catalog.*": {"queue": "default"},
|
||||
"web.tasks.vk_catalog.*": {"queue": "default"},
|
||||
"web.notifications.tasks.*": {"queue": "notifications"},
|
||||
},
|
||||
beat_schedule={
|
||||
"refresh-catalog": {
|
||||
"task": "web.tasks.catalog.refresh_catalog",
|
||||
"schedule": timedelta(seconds=settings.CATALOG_REFRESH_INTERVAL_SECONDS),
|
||||
},
|
||||
"refresh-vk-catalog": {
|
||||
"task": "web.tasks.vk_catalog.refresh_vk_catalog",
|
||||
# Chain: fetch Evotor → fetch VK catalog → mirror Evotor→VK
|
||||
# Beat fires the launcher task which chains all three sequentially.
|
||||
"sync-pipeline": {
|
||||
"task": "web.tasks.celery_app.run_sync_pipeline",
|
||||
"schedule": timedelta(seconds=settings.CATALOG_REFRESH_INTERVAL_SECONDS),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Register task modules so beat/worker can discover them
|
||||
celery_app.autodiscover_tasks(["web.tasks.catalog", "web.tasks.vk_catalog"])
|
||||
celery_app.autodiscover_tasks([
|
||||
"web.tasks.catalog",
|
||||
"web.tasks.vk_catalog",
|
||||
"web.tasks.vk_sync",
|
||||
"web.tasks.celery_app",
|
||||
])
|
||||
|
||||
|
||||
@celery_app.task(name="web.tasks.celery_app.run_sync_pipeline", queue="default")
|
||||
def run_sync_pipeline() -> str:
|
||||
"""
|
||||
Beat entry point. Chains refresh_catalog → refresh_vk_catalog → mirror_to_vk
|
||||
so that mirror only runs after both catalog fetches are complete.
|
||||
"""
|
||||
from celery import chain
|
||||
from web.tasks.catalog import refresh_catalog
|
||||
from web.tasks.vk_catalog import refresh_vk_catalog
|
||||
from web.tasks.vk_sync import mirror_to_vk
|
||||
|
||||
chain(
|
||||
refresh_catalog.si(),
|
||||
refresh_vk_catalog.si(),
|
||||
mirror_to_vk.si(),
|
||||
).apply_async()
|
||||
return "pipeline dispatched"
|
||||
|
||||
@@ -91,13 +91,20 @@ def _sync_user(db, user_id: int, token: str, group_id: str) -> None:
|
||||
for p in all_products:
|
||||
pid = str(p["id"])
|
||||
album_id = str(p["albums_ids"][0]) if p.get("albums_ids") else None
|
||||
price_raw = p.get("price", {}).get("amount")
|
||||
price = float(price_raw) / 100 if price_raw is not None else None
|
||||
price_field = p.get("price")
|
||||
if isinstance(price_field, dict):
|
||||
price_raw = price_field.get("amount")
|
||||
price = float(price_raw) / 100 if price_raw is not None else None
|
||||
else:
|
||||
price = float(price_field) / 100 if price_field is not None else None
|
||||
thumb = None
|
||||
if p.get("thumb_photo"):
|
||||
sizes = p["thumb_photo"].get("sizes", [])
|
||||
thumb_field = p.get("thumb_photo")
|
||||
if isinstance(thumb_field, dict):
|
||||
sizes = thumb_field.get("sizes", [])
|
||||
if sizes:
|
||||
thumb = sizes[-1].get("url")
|
||||
elif isinstance(thumb_field, str):
|
||||
thumb = thumb_field
|
||||
|
||||
row = db.query(VkCachedProduct).filter_by(
|
||||
user_id=user_id, vk_group_id=group_id, vk_product_id=pid,
|
||||
|
||||
409
web/tasks/vk_sync.py
Normal file
409
web/tasks/vk_sync.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Mirror Evotor product catalog → VK Market.
|
||||
|
||||
Runs after both refresh_catalog and refresh_vk_catalog complete.
|
||||
Only processes stores and groups that have sync enabled (via SyncFilter).
|
||||
Updates VK products only when at least one synced field has changed.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
from celery import shared_task
|
||||
|
||||
from web.config import settings
|
||||
from web.database import SessionLocal
|
||||
from web.models.connections import (
|
||||
CachedGroup, CachedProduct, CachedStore,
|
||||
SyncConfig, SyncFilter,
|
||||
VkCachedAlbum, VkConnection,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VK_API = "https://api.vk.com/method"
|
||||
|
||||
WEIGHT_MEASURES = {"г", "г.", "грамм", "граммов", "гр", "гр."}
|
||||
|
||||
_PHOTO_CACHE: dict[int, str] = {} # user_id → uploaded photo_id for this run
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _is_weight(measure: str | None) -> bool:
|
||||
return (measure or "").strip().lower() in WEIGHT_MEASURES
|
||||
|
||||
|
||||
def _calc_price(price: Decimal | None, measure: str | None) -> int:
|
||||
"""Return price in VK kopecks (integer). Weight items are multiplied."""
|
||||
if price is None:
|
||||
return 0
|
||||
base = int(price)
|
||||
if _is_weight(measure):
|
||||
base *= settings.VK_WEIGHT_PRICE_MULTIPLIER
|
||||
return base * 100 # kopecks
|
||||
|
||||
|
||||
def _build_description(name: str, measure: str | None, evo_description: str | None) -> str:
|
||||
if _is_weight(measure):
|
||||
price_info = f"{settings.VK_WEIGHT_PRICE_MULTIPLIER}{(measure or '').strip()}"
|
||||
else:
|
||||
price_info = (measure or "").strip()
|
||||
desc = f"{name} (цена за {price_info}.)\n\n"
|
||||
if evo_description:
|
||||
desc += evo_description
|
||||
return desc
|
||||
|
||||
|
||||
def _name_for_vk(name: str) -> str:
|
||||
return name.replace(";", ",")
|
||||
|
||||
|
||||
def _vk_post(method: str, data: dict, token: str) -> dict:
|
||||
data = {**data, "access_token": token, "v": settings.VK_API_VERSION}
|
||||
r = httpx.post(f"{VK_API}/{method}", data=data, timeout=30)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _upload_photo(token: str, group_id: str) -> str | None:
|
||||
"""Upload the default product photo and return photo_id, or None on failure."""
|
||||
photo_path = settings.VK_DEFAULT_PHOTO_PATH
|
||||
if not os.path.exists(photo_path):
|
||||
logger.warning("Default photo not found at %s", photo_path)
|
||||
return None
|
||||
try:
|
||||
# Step 1: get upload URL
|
||||
resp = _vk_post("market.getProductPhotoUploadServer", {"group_id": group_id}, token)
|
||||
if "error" in resp:
|
||||
logger.warning("getProductPhotoUploadServer error: %s", resp["error"])
|
||||
return None
|
||||
upload_url = resp["response"]["upload_url"]
|
||||
|
||||
# Step 2: upload file
|
||||
with open(photo_path, "rb") as f:
|
||||
up = httpx.post(upload_url, files={"file": f}, timeout=30)
|
||||
up.raise_for_status()
|
||||
upload_obj = up.text
|
||||
|
||||
# Step 3: save
|
||||
resp2 = _vk_post("market.saveProductPhoto", {"upload_response": upload_obj}, token)
|
||||
if "error" in resp2:
|
||||
logger.warning("saveProductPhoto error: %s", resp2["error"])
|
||||
return None
|
||||
return str(resp2["response"]["photo_id"])
|
||||
except Exception as e:
|
||||
logger.warning("Photo upload failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _get_photo_id(user_id: int, token: str, group_id: str) -> str | None:
|
||||
"""Upload photo once per sync run per user, cache the result."""
|
||||
if user_id not in _PHOTO_CACHE:
|
||||
_PHOTO_CACHE[user_id] = _upload_photo(token, group_id)
|
||||
return _PHOTO_CACHE[user_id]
|
||||
|
||||
|
||||
def _enabled_store_ids(db, user_id: int) -> set[str] | None:
|
||||
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
|
||||
if not cfg:
|
||||
return None
|
||||
filters = db.query(SyncFilter).filter_by(
|
||||
sync_config_id=cfg.id, entity_type="store", filter_mode="include",
|
||||
).all()
|
||||
return None if not filters else {f.entity_id for f in filters}
|
||||
|
||||
|
||||
def _enabled_group_ids(db, user_id: int, store_evotor_id: str) -> set[str] | None:
|
||||
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
|
||||
if not cfg:
|
||||
return None
|
||||
filters = db.query(SyncFilter).filter_by(
|
||||
sync_config_id=cfg.id, entity_type="group", filter_mode="include",
|
||||
parent_entity_id=store_evotor_id,
|
||||
).all()
|
||||
return None if not filters else {f.entity_id for f in filters}
|
||||
|
||||
|
||||
def _ensure_album(db, user_id: int, vk_group_id: str, group_name: str, token: str) -> str | None:
|
||||
"""Return VK album_id for the given group name, creating it if needed."""
|
||||
album = db.query(VkCachedAlbum).filter_by(
|
||||
user_id=user_id, vk_group_id=vk_group_id,
|
||||
).filter(VkCachedAlbum.title == group_name).first()
|
||||
|
||||
if album:
|
||||
return album.album_id
|
||||
|
||||
# Create album in VK
|
||||
resp = _vk_post("market.addAlbum", {
|
||||
"owner_id": f"-{vk_group_id}",
|
||||
"title": group_name,
|
||||
}, token)
|
||||
if "error" in resp:
|
||||
logger.warning("market.addAlbum error for '%s': %s", group_name, resp["error"])
|
||||
return None
|
||||
|
||||
album_id = str(resp["response"]["market_album_id"])
|
||||
db.add(VkCachedAlbum(
|
||||
user_id=user_id,
|
||||
vk_group_id=vk_group_id,
|
||||
album_id=album_id,
|
||||
title=group_name,
|
||||
fetched_at=_now(),
|
||||
))
|
||||
db.flush()
|
||||
logger.info("user=%s created VK album '%s' id=%s", user_id, group_name, album_id)
|
||||
return album_id
|
||||
|
||||
|
||||
def _sync_product(
|
||||
db,
|
||||
user_id: int,
|
||||
product: CachedProduct,
|
||||
album_id: str,
|
||||
vk_group_id: str,
|
||||
token: str,
|
||||
) -> None:
|
||||
name = _name_for_vk(product.name)
|
||||
price_kopecks = _calc_price(product.price, product.measure_name)
|
||||
desc = _build_description(product.name, product.measure_name, None)
|
||||
stock = settings.VK_STOCK_AMOUNT if product.allow_to_sell else 0
|
||||
owner_id = f"-{vk_group_id}"
|
||||
now = _now()
|
||||
|
||||
if product.vk_product_id:
|
||||
# Check if update needed
|
||||
changed = False
|
||||
# Re-read current VK state from cache
|
||||
from web.models.connections import VkCachedProduct
|
||||
vk_p = db.query(VkCachedProduct).filter_by(
|
||||
user_id=user_id, vk_group_id=vk_group_id, vk_product_id=product.vk_product_id,
|
||||
).first()
|
||||
if vk_p:
|
||||
vk_price_kopecks = int(vk_p.price * 100) if vk_p.price is not None else 0
|
||||
vk_stock = settings.VK_STOCK_AMOUNT if vk_p.availability == 0 else 0
|
||||
vk_name = vk_p.name or ""
|
||||
vk_desc = (vk_p.description or "").strip()
|
||||
curr_desc = desc.strip()
|
||||
changed = (
|
||||
name != vk_name
|
||||
or price_kopecks != vk_price_kopecks
|
||||
or curr_desc != vk_desc
|
||||
or stock != vk_stock
|
||||
)
|
||||
else:
|
||||
changed = True # cached VK product gone, push update
|
||||
|
||||
if not changed:
|
||||
return
|
||||
|
||||
resp = _vk_post("market.edit", {
|
||||
"owner_id": owner_id,
|
||||
"item_id": product.vk_product_id,
|
||||
"name": name,
|
||||
"description": desc.strip(),
|
||||
"category_id": settings.VK_CATEGORY_ID,
|
||||
"price": price_kopecks,
|
||||
"stock_amount": stock,
|
||||
}, token)
|
||||
if "error" in resp:
|
||||
logger.warning("market.edit error product=%s: %s", product.evotor_id, resp["error"])
|
||||
return
|
||||
product.synced_at = now
|
||||
logger.info("user=%s updated VK product '%s' id=%s", user_id, name, product.vk_product_id)
|
||||
|
||||
else:
|
||||
# Create — only if allow_to_sell
|
||||
if not product.allow_to_sell:
|
||||
return
|
||||
|
||||
photo_id = _get_photo_id(user_id, token, vk_group_id)
|
||||
if not photo_id:
|
||||
logger.warning("user=%s skipping create for '%s': no photo", user_id, name)
|
||||
return
|
||||
|
||||
resp = _vk_post("market.add", {
|
||||
"owner_id": owner_id,
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"category_id": settings.VK_CATEGORY_ID,
|
||||
"price": price_kopecks,
|
||||
"main_photo_id": photo_id,
|
||||
"stock_amount": stock,
|
||||
}, token)
|
||||
if "error" in resp:
|
||||
logger.warning("market.add error product=%s: %s", product.evotor_id, resp["error"])
|
||||
return
|
||||
|
||||
vk_item_id = str(resp["response"]["market_item_id"])
|
||||
product.vk_product_id = vk_item_id
|
||||
product.synced_at = now
|
||||
|
||||
# Add to album
|
||||
resp2 = _vk_post("market.addToAlbum", {
|
||||
"owner_id": owner_id,
|
||||
"item_ids": vk_item_id,
|
||||
"album_ids": album_id,
|
||||
}, token)
|
||||
if "error" in resp2:
|
||||
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp2["error"])
|
||||
|
||||
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):
|
||||
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)
|
||||
if product.vk_product_id:
|
||||
owned_ids.add(product.vk_product_id)
|
||||
if product.synced_at:
|
||||
if was_new and product.vk_product_id:
|
||||
results["created"] += 1
|
||||
elif not was_new:
|
||||
results["updated"] += 1
|
||||
else:
|
||||
results["skipped"] += 1
|
||||
else:
|
||||
results["skipped"] += 1
|
||||
except Exception as e:
|
||||
logger.error("user=%s sync_product failed '%s': %s", user_id, product.name, e)
|
||||
results["errors"] += 1
|
||||
|
||||
|
||||
def _delete_orphans(db, user_id, vk_group_id, owned_ids, token, results):
|
||||
from web.models.connections import VkCachedProduct
|
||||
orphans = db.query(VkCachedProduct).filter_by(
|
||||
user_id=user_id, vk_group_id=vk_group_id,
|
||||
).filter(VkCachedProduct.vk_product_id.notin_(owned_ids)).all() if owned_ids else \
|
||||
db.query(VkCachedProduct).filter_by(user_id=user_id, vk_group_id=vk_group_id).all()
|
||||
|
||||
owner_id = f"-{vk_group_id}"
|
||||
for vk_p in orphans:
|
||||
try:
|
||||
resp = _vk_post("market.delete", {
|
||||
"owner_id": owner_id,
|
||||
"item_id": vk_p.vk_product_id,
|
||||
}, token)
|
||||
if "error" in resp:
|
||||
logger.warning("market.delete error id=%s: %s", vk_p.vk_product_id, resp["error"])
|
||||
results["errors"] += 1
|
||||
else:
|
||||
logger.info("user=%s deleted VK product id=%s '%s'", user_id, vk_p.vk_product_id, vk_p.name)
|
||||
db.delete(vk_p)
|
||||
results["deleted"] += 1
|
||||
except Exception as e:
|
||||
logger.error("user=%s delete failed id=%s: %s", user_id, vk_p.vk_product_id, e)
|
||||
results["errors"] += 1
|
||||
|
||||
# Also clear vk_product_id on any cached_products that pointed to deleted VK items
|
||||
if orphans:
|
||||
deleted_vk_ids = {vk_p.vk_product_id for vk_p in orphans}
|
||||
stale = db.query(CachedProduct).filter(
|
||||
CachedProduct.user_id == user_id,
|
||||
CachedProduct.vk_product_id.in_(deleted_vk_ids),
|
||||
).all()
|
||||
for p in stale:
|
||||
p.vk_product_id = None
|
||||
|
||||
|
||||
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
|
||||
|
||||
enabled_stores = _enabled_store_ids(db, user_id)
|
||||
|
||||
stores = db.query(CachedStore).filter_by(user_id=user_id).all()
|
||||
for store in stores:
|
||||
if enabled_stores is not None and store.evotor_id not in enabled_stores:
|
||||
continue
|
||||
|
||||
enabled_groups = _enabled_group_ids(db, user_id, store.evotor_id)
|
||||
|
||||
groups = db.query(CachedGroup).filter_by(
|
||||
user_id=user_id, store_evotor_id=store.evotor_id,
|
||||
).all()
|
||||
|
||||
for group in groups:
|
||||
if enabled_groups is not None and group.evotor_id not in enabled_groups:
|
||||
continue
|
||||
|
||||
try:
|
||||
album_id = _ensure_album(db, user_id, vk_group_id, group.name, token)
|
||||
except Exception as e:
|
||||
logger.error("user=%s ensure_album failed for '%s': %s", user_id, group.name, e)
|
||||
results["errors"] += 1
|
||||
continue
|
||||
if not album_id:
|
||||
results["errors"] += 1
|
||||
continue
|
||||
|
||||
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)
|
||||
|
||||
# Ungrouped products → "Без категории" album
|
||||
ungrouped = db.query(CachedProduct).filter_by(
|
||||
user_id=user_id, store_evotor_id=store.evotor_id, group_evotor_id=None,
|
||||
).all()
|
||||
if ungrouped:
|
||||
try:
|
||||
fallback_album_id = _ensure_album(db, user_id, vk_group_id, "Без категории", token)
|
||||
except Exception as e:
|
||||
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)
|
||||
|
||||
# Delete VK products not owned by any Evotor product
|
||||
_delete_orphans(db, user_id, vk_group_id, owned_ids, token, results)
|
||||
|
||||
db.commit()
|
||||
return results
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="web.tasks.vk_sync.mirror_to_vk",
|
||||
queue="sync",
|
||||
bind=True,
|
||||
max_retries=1,
|
||||
default_retry_delay=120,
|
||||
)
|
||||
def mirror_to_vk(self) -> dict:
|
||||
"""Mirror Evotor catalog → VK Market for all users with both connections active."""
|
||||
_PHOTO_CACHE.clear()
|
||||
db = SessionLocal()
|
||||
totals = {"ok": 0, "failed": 0}
|
||||
try:
|
||||
vk_connections = (
|
||||
db.query(VkConnection)
|
||||
.filter(
|
||||
VkConnection.user_id.isnot(None),
|
||||
VkConnection.access_token.isnot(None),
|
||||
VkConnection.access_token != "",
|
||||
VkConnection.vk_user_id.isnot(None),
|
||||
VkConnection.vk_user_id != "",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for conn in vk_connections:
|
||||
cfg = db.query(SyncConfig).filter_by(user_id=conn.user_id).first()
|
||||
if not cfg or not cfg.is_enabled:
|
||||
continue
|
||||
try:
|
||||
result = _sync_user(db, conn.user_id, conn.access_token, conn.vk_user_id)
|
||||
logger.info("user=%s mirror_to_vk: %s", conn.user_id, result)
|
||||
totals["ok"] += 1
|
||||
except Exception as exc:
|
||||
logger.error("mirror_to_vk failed for user=%s: %s", conn.user_id, exc)
|
||||
totals["failed"] += 1
|
||||
finally:
|
||||
db.close()
|
||||
logger.info("mirror_to_vk done: %s", totals)
|
||||
return totals
|
||||
@@ -23,6 +23,7 @@
|
||||
<tr>
|
||||
<th>Синхронизация</th>
|
||||
<th>Название</th>
|
||||
<th>Количество товаров</th>
|
||||
<th>ID</th>
|
||||
<th>Обновлено</th>
|
||||
<th></th>
|
||||
@@ -47,6 +48,7 @@
|
||||
</form>
|
||||
</td>
|
||||
<td><i class="bi bi-folder2 me-1 text-muted"></i> <strong>{{ g.name }}</strong></td>
|
||||
<td class="text-muted small">{{ product_counts.get(g.evotor_id, 0) }}</td>
|
||||
<td class="text-muted small">{{ g.evotor_id }}</td>
|
||||
<td class="text-muted small">{{ g.fetched_at | datefmt }}</td>
|
||||
<td>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Группа</th>
|
||||
<th>Артикул</th>
|
||||
<th>Цена</th>
|
||||
<th>Остаток</th>
|
||||
@@ -52,6 +53,7 @@
|
||||
{% for p in products %}
|
||||
<tr>
|
||||
<td>{{ p.name }}</td>
|
||||
<td class="text-muted small">{{ group_map.get(p.group_evotor_id) or '—' }}</td>
|
||||
<td class="text-muted small">{{ p.article_number or '—' }}</td>
|
||||
<td>{% if p.price is not none %}{{ p.price | price }}{% else %}—{% endif %}</td>
|
||||
<td>{% if p.quantity is not none %}{{ p.quantity }}{% else %}—{% endif %}</td>
|
||||
|
||||
@@ -134,14 +134,15 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body">
|
||||
<details {% if not vk %}open{% endif %}>
|
||||
<summary>
|
||||
{% if vk %}Обновить подключение{% else %}Подключить ВКонтакте{% endif %}
|
||||
</summary>
|
||||
<p class="text-muted small mt-2">
|
||||
Укажите токен пользователя VK с правами <code>market,photos,groups</code>
|
||||
и ID сообщества, в котором включён Маркет.
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<a href="/vk-auth" role="button">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i>
|
||||
{% if vk %}Переподключить ВКонтакте{% else %}Войти через ВКонтакте{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<details class="mt-3">
|
||||
<summary class="text-muted small">Ввести токен вручную</summary>
|
||||
<form method="post" action="/connections/vk" class="mt-2">
|
||||
<label>
|
||||
Токен доступа VK
|
||||
|
||||
Reference in New Issue
Block a user