diff --git a/backend/bot/app/web/webapp/devices.py b/backend/bot/app/web/webapp/devices.py index 2aefe9f..5fe69b3 100644 --- a/backend/bot/app/web/webapp/devices.py +++ b/backend/bot/app/web/webapp/devices.py @@ -22,7 +22,13 @@ async def devices_route(request: web.Request) -> web.Response: "devices", user_id, int(getattr(settings, "WEBAPP_DEVICES_CACHE_TTL_SECONDS", 5) or 0), - lambda: _load_devices_payload(subscription_service, session, user_id), + lambda: _load_devices_payload( + subscription_service, + session, + user_id, + fallback_panel_user_uuid=str(getattr(db_user, "panel_user_uuid", "") or "").strip() + or None, + ), ) if isinstance(result, dict) and result.get("ok") is True: return web.json_response({"ok": True, **(result.get("payload") or {})}) @@ -45,16 +51,12 @@ async def _load_devices_payload( subscription_service: SubscriptionService, session: AsyncSession, user_id: int, + fallback_panel_user_uuid: Optional[str] = None, ) -> Dict[str, Any]: active = await subscription_service.get_active_subscription_details(session, user_id) - panel_user_uuid = active.get("user_id") if active else None + panel_user_uuid = str((active or {}).get("user_id") or fallback_panel_user_uuid or "").strip() if not panel_user_uuid: - return { - "ok": False, - "status": 400, - "error": "subscription_not_active", - "message": "Subscription is not active", - } + return _empty_inactive_devices_payload() panel_service = getattr(subscription_service, "panel_service", None) if not panel_service: @@ -82,6 +84,7 @@ async def _load_devices_payload( "ok": True, "payload": { "enabled": True, + "subscription_active": _devices_subscription_is_active(active), "current_devices": len(devices), "max_devices": max_devices, "max_devices_label": _format_devices_limit(max_devices), @@ -92,6 +95,31 @@ async def _load_devices_payload( } +def _empty_inactive_devices_payload() -> Dict[str, Any]: + return { + "ok": True, + "payload": { + "enabled": True, + "subscription_active": False, + "current_devices": 0, + "max_devices": None, + "max_devices_label": _format_devices_limit(None), + "devices": [], + }, + } + + +def _devices_subscription_is_active(active: Optional[Dict[str, Any]]) -> bool: + if not active: + return False + end_date = active.get("end_date") + if not isinstance(end_date, datetime): + return False + if end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=timezone.utc) + return end_date > datetime.now(timezone.utc) + + async def disconnect_device_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) rate_limit_response = await _enforce_webapp_rate_limit( diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 0470e10..09e98fb 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -323,6 +323,7 @@ devicesBusy, devicesStatus, devicesIsError, + devicesErrorCode, deviceConfirmOpen, deviceToDisconnect, deviceDisconnectBusy, @@ -2185,6 +2186,7 @@ {devicesData} {devicesIsError} {devicesLoaded} + {devicesErrorCode} {devicesStatus} {subscription} {loadDevices} diff --git a/frontend/src/lib/webapp/previewMock.js b/frontend/src/lib/webapp/previewMock.js index 022e849..2c64327 100644 --- a/frontend/src/lib/webapp/previewMock.js +++ b/frontend/src/lib/webapp/previewMock.js @@ -528,6 +528,15 @@ function applyInactiveSubscriptionScenario({ trialAvailable = false } = {}) { premium_limit_bytes: 0, premium_is_limited: false, }; + DEV_MOCK.data.settings.my_devices_enabled = true; + DEV_MOCK.data.devices = { + ok: true, + enabled: true, + current_devices: 0, + max_devices: 0, + max_devices_label: "∞", + devices: [], + }; DEV_MOCK.data.plans = DEMO_DATASET.plans || DEV_MOCK.data.plans; DEV_MOCK.data.tariff_change_options = DEMO_DATASET.tariff_change_options || DEV_MOCK.data.tariff_change_options; diff --git a/frontend/src/lib/webapp/stores/devicesStore.js b/frontend/src/lib/webapp/stores/devicesStore.js index cb01701..da0e7df 100644 --- a/frontend/src/lib/webapp/stores/devicesStore.js +++ b/frontend/src/lib/webapp/stores/devicesStore.js @@ -7,6 +7,7 @@ export function createDevicesStore({ api, t, showToast }) { devicesBusy: false, devicesStatus: "", devicesIsError: false, + devicesErrorCode: "", deviceConfirmOpen: false, deviceToDisconnect: null, deviceDisconnectBusy: false, @@ -15,16 +16,28 @@ export function createDevicesStore({ api, t, showToast }) { async function loadDevices(devicesEnabled, force = false) { const s = get(state); if (!devicesEnabled || s.devicesBusy || (s.devicesLoaded && !force)) return; - state.update((s) => ({ ...s, devicesBusy: true, devicesStatus: "", devicesIsError: false })); + state.update((s) => ({ + ...s, + devicesBusy: true, + devicesStatus: "", + devicesIsError: false, + devicesErrorCode: "", + })); try { const response = await api("/devices"); if (!response?.ok) throw response; - state.update((s) => ({ ...s, devicesData: response, devicesLoaded: true })); + state.update((s) => ({ + ...s, + devicesData: response, + devicesLoaded: true, + devicesErrorCode: "", + })); } catch (error) { state.update((s) => ({ ...s, devicesStatus: error?.message || t("wa_devices_load_failed"), devicesIsError: true, + devicesErrorCode: String(error?.error || ""), devicesLoaded: true, })); } finally { diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index 0a99321..7d95460 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -913,6 +913,14 @@ a { color: var(--accent); } +.devices-inactive-card { + border-color: color-mix(in srgb, var(--danger) 58%, var(--border)); +} + +.devices-inactive-card svg { + color: var(--danger); +} + .action-stack { display: grid; gap: 8px; diff --git a/frontend/src/webapp/screens/DevicesScreen.svelte b/frontend/src/webapp/screens/DevicesScreen.svelte index 867505c..1a42525 100644 --- a/frontend/src/webapp/screens/DevicesScreen.svelte +++ b/frontend/src/webapp/screens/DevicesScreen.svelte @@ -12,6 +12,7 @@ export let devicesBusy = false; export let devicesData = {}; + export let devicesErrorCode = ""; export let devicesIsError = false; export let devicesLoaded = false; export let devicesStatus = ""; @@ -21,54 +22,72 @@ export let openDeviceDisconnectDialog = () => {}; export let openDeviceTopupModal = () => {}; export let t = (key) => key; + + $: deviceList = Array.isArray(devicesData?.devices) ? devicesData.devices : []; + $: hasDevices = deviceList.length > 0; + $: subscriptionNotActiveError = + devicesErrorCode === "subscription_not_active" || + devicesStatus === "Subscription is not active"; + $: hideDevicesSummary = !subscription?.active && !hasDevices; + $: showInactiveDevicesNotice = + hideDevicesSummary && + !(devicesBusy && !devicesLoaded) && + (!devicesStatus || subscriptionNotActiveError);
- -
- - - {t("wa_devices_title")} - {devicesCountLabel(devicesData, t)} - - -
- - {#if Number(subscription?.extra_hwid_devices || 0) > 0 && subscription?.extra_hwid_devices_valid_until_text} -

- {t("wa_hwid_devices_valid_until", { - count: Number(subscription.extra_hwid_devices || 0), - date: subscription.extra_hwid_devices_valid_until_text, - })} -

- {/if} - {#if subscription?.active && subscription?.max_devices !== 0 && subscription?.can_topup_devices} - - {/if} -
+ {#if !hideDevicesSummary} + +
+ + + {t("wa_devices_title")} + {devicesCountLabel(devicesData, t)} + + +
+ + {#if Number(subscription?.extra_hwid_devices || 0) > 0 && subscription?.extra_hwid_devices_valid_until_text} +

+ {t("wa_hwid_devices_valid_until", { + count: Number(subscription.extra_hwid_devices || 0), + date: subscription.extra_hwid_devices_valid_until_text, + })} +

+ {/if} + {#if subscription?.active && subscription?.max_devices !== 0 && subscription?.can_topup_devices} + + {/if} +
+ {/if} {#if devicesBusy && !devicesLoaded} {t("wa_devices_loading")} + {:else if showInactiveDevicesNotice} + + + {t("wa_home_subscription_inactive")} + {:else if devicesStatus} {devicesStatus} - {:else if !devicesData?.devices?.length} + {:else if !hasDevices} {t("wa_devices_empty")} @@ -76,7 +95,7 @@ {:else}
- {#each devicesData.devices as device (device.token || device.index)} + {#each deviceList as device (device.token || device.index)}
diff --git a/tests/test_webapp_devices_serialization.py b/tests/test_webapp_devices_serialization.py index 591b49e..09a98de 100644 --- a/tests/test_webapp_devices_serialization.py +++ b/tests/test_webapp_devices_serialization.py @@ -1,8 +1,11 @@ import json from datetime import datetime, timezone +from types import SimpleNamespace +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock import bot.app.web.subscription_webapp # noqa: F401 -from bot.app.web.webapp.devices import _serialize_device +from bot.app.web.webapp.devices import _load_devices_payload, _serialize_device def test_device_serializer_accepts_datetime_created_at(): @@ -20,3 +23,49 @@ def test_device_serializer_accepts_datetime_created_at(): assert payload["created_at"] == created_at.isoformat() assert payload["created_at_text"] == "02.01.2099 03:04" json.dumps(payload) + + +class WebAppDevicesPayloadTests(IsolatedAsyncioTestCase): + async def test_load_devices_payload_returns_empty_payload_without_subscription(self): + panel_service = SimpleNamespace(get_user_devices=AsyncMock()) + subscription_service = SimpleNamespace( + get_active_subscription_details=AsyncMock(return_value=None), + panel_service=panel_service, + ) + + payload = await _load_devices_payload(subscription_service, AsyncMock(), 42) + + self.assertTrue(payload["ok"]) + self.assertFalse(payload["payload"]["subscription_active"]) + self.assertEqual(payload["payload"]["current_devices"], 0) + self.assertEqual(payload["payload"]["devices"], []) + panel_service.get_user_devices.assert_not_awaited() + + async def test_load_devices_payload_uses_fallback_panel_uuid_for_inactive_devices(self): + panel_service = SimpleNamespace( + get_user_devices=AsyncMock( + return_value=[ + { + "hwid": "abcdef123456", + "deviceModel": "Laptop", + } + ] + ) + ) + subscription_service = SimpleNamespace( + get_active_subscription_details=AsyncMock(return_value=None), + panel_service=panel_service, + ) + + payload = await _load_devices_payload( + subscription_service, + AsyncMock(), + 42, + fallback_panel_user_uuid="panel-user", + ) + + self.assertTrue(payload["ok"]) + self.assertFalse(payload["payload"]["subscription_active"]) + self.assertEqual(payload["payload"]["current_devices"], 1) + self.assertEqual(payload["payload"]["devices"][0]["display_name"], "Laptop") + panel_service.get_user_devices.assert_awaited_once_with("panel-user")