diff --git a/tests/test_routes_catalog.py b/tests/test_routes_catalog.py new file mode 100644 index 0000000..eaec0c3 --- /dev/null +++ b/tests/test_routes_catalog.py @@ -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 diff --git a/tests/test_routes_connections.py b/tests/test_routes_connections.py new file mode 100644 index 0000000..f23ab05 --- /dev/null +++ b/tests/test_routes_connections.py @@ -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"] diff --git a/tests/test_tasks_catalog.py b/tests/test_tasks_catalog.py new file mode 100644 index 0000000..b64595d --- /dev/null +++ b/tests/test_tasks_catalog.py @@ -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 diff --git a/tests/test_tasks_vk_sync.py b/tests/test_tasks_vk_sync.py new file mode 100644 index 0000000..de1ab1b --- /dev/null +++ b/tests/test_tasks_vk_sync.py @@ -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 diff --git a/web/config.py b/web/config.py index 877d235..3d13d2b 100644 --- a/web/config.py +++ b/web/config.py @@ -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 diff --git a/web/migrations/versions/0005_cached_products_vk_product_id.py b/web/migrations/versions/0005_cached_products_vk_product_id.py new file mode 100644 index 0000000..f3a14dd --- /dev/null +++ b/web/migrations/versions/0005_cached_products_vk_product_id.py @@ -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") diff --git a/web/migrations/versions/0006_vk_connection_token_fields.py b/web/migrations/versions/0006_vk_connection_token_fields.py new file mode 100644 index 0000000..d774788 --- /dev/null +++ b/web/migrations/versions/0006_vk_connection_token_fields.py @@ -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") diff --git a/web/models/connections.py b/web/models/connections.py index 108cbdd..564b34f 100644 --- a/web/models/connections.py +++ b/web/models/connections.py @@ -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"), diff --git a/web/routes/catalog.py b/web/routes/catalog.py index 580aa77..9b728a2 100644 --- a/web/routes/catalog.py +++ b/web/routes/catalog.py @@ -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, }) diff --git a/web/routes/connections.py b/web/routes/connections.py index df8644c..8156a9b 100644 --- a/web/routes/connections.py +++ b/web/routes/connections.py @@ -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(""" + +VK авторизация… + + + +
+
+

Завершаем авторизацию…

+
+ + +""") + + +@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: diff --git a/web/tasks/celery_app.py b/web/tasks/celery_app.py index ba340fc..92aaed9 100644 --- a/web/tasks/celery_app.py +++ b/web/tasks/celery_app.py @@ -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" diff --git a/web/tasks/vk_catalog.py b/web/tasks/vk_catalog.py index e274892..aa294fa 100644 --- a/web/tasks/vk_catalog.py +++ b/web/tasks/vk_catalog.py @@ -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, diff --git a/web/tasks/vk_sync.py b/web/tasks/vk_sync.py new file mode 100644 index 0000000..24719c7 --- /dev/null +++ b/web/tasks/vk_sync.py @@ -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 diff --git a/web/templates/catalog/groups.html b/web/templates/catalog/groups.html index ac2df14..d7fbbb2 100644 --- a/web/templates/catalog/groups.html +++ b/web/templates/catalog/groups.html @@ -23,6 +23,7 @@ Синхронизация Название + Количество товаров ID Обновлено @@ -47,6 +48,7 @@ {{ g.name }} + {{ product_counts.get(g.evotor_id, 0) }} {{ g.evotor_id }} {{ g.fetched_at | datefmt }} diff --git a/web/templates/catalog/products.html b/web/templates/catalog/products.html index 05a4518..30925d2 100644 --- a/web/templates/catalog/products.html +++ b/web/templates/catalog/products.html @@ -40,6 +40,7 @@ Название + Группа Артикул Цена Остаток @@ -52,6 +53,7 @@ {% for p in products %} {{ p.name }} + {{ group_map.get(p.group_evotor_id) or '—' }} {{ p.article_number or '—' }} {% if p.price is not none %}{{ p.price | price }}{% else %}—{% endif %} {% if p.quantity is not none %}{{ p.quantity }}{% else %}—{% endif %} diff --git a/web/templates/connections.html b/web/templates/connections.html index 8851344..aed99b2 100644 --- a/web/templates/connections.html +++ b/web/templates/connections.html @@ -134,14 +134,15 @@ {% endif %}
-
- - {% if vk %}Обновить подключение{% else %}Подключить ВКонтакте{% endif %} - -

- Укажите токен пользователя VK с правами market,photos,groups - и ID сообщества, в котором включён Маркет. -

+ + +
+ Ввести токен вручную