mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 09:35:31 -03:00
feat(update): add a Buzz price alerts panel to the notification bell
P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what got cheaper / became free", without a permanent button (the grid filter was dropped by owner decision, so the panel carries the actions itself). - price_alert_since records when an alert started, so the panel can say "dropped 3 d ago" and count what is new since the user last looked; it is set on the first sight of an already-cheap version, preserved while the alert stands, and cleared when the price rises back above the threshold - get_price_alerts() compares the threshold at read time (editing it takes effect immediately, no refresh needed) and returns both kinds in one list; model_type=None covers every type, which the shared update DB makes a single query. "became free" needs no price data, so it is reported even while price tracking is off - GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model type, decorating rows best-effort with the local model name and file path from the scanner indexes (a cold cache just omits them) - a third tab in the notification bell: segments for under-threshold and became-free, the three states (tracking off / nothing matching / stale), and per-row actions (CivitAI always, Open when the model is local) - two non-permanent entry points share one helper: the controls-bar updates dropdown and the global context menu, whose label carries the unread count - unread state stays client-side (localStorage watermark); the count is fetched once on init and only when price tracking is enabled - the per-type frontend client method is removed as dead code; the per-type backend route stays for the companion extension
This commit is contained in:
@@ -6,7 +6,7 @@ import subprocess
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
@@ -16,6 +16,8 @@ from py.routes.handlers import misc_handlers
|
||||
from py.routes.handlers.misc_handlers import (
|
||||
BackupHandler,
|
||||
DoctorHandler,
|
||||
MiscHandlerSet,
|
||||
PriceAlertsHandler,
|
||||
FileSystemHandler,
|
||||
HealthCheckHandler,
|
||||
LoraCodeHandler,
|
||||
@@ -2865,3 +2867,220 @@ async def test_sidecar_migration_handler_relocate_root_requires_old_root():
|
||||
assert response.status == 400
|
||||
assert "old_root" in payload["error"]
|
||||
assert use_case.calls == []
|
||||
|
||||
|
||||
# --- Global price alerts panel endpoint -------------------------------------
|
||||
|
||||
|
||||
class _AnyHandler:
|
||||
def __getattr__(self, _name):
|
||||
return lambda request: None
|
||||
|
||||
|
||||
def _stub_misc_handler_set(**overrides) -> MiscHandlerSet:
|
||||
names = (
|
||||
"health",
|
||||
"settings",
|
||||
"usage_stats",
|
||||
"lora_code",
|
||||
"trained_words",
|
||||
"model_examples",
|
||||
"node_registry",
|
||||
"model_library",
|
||||
"metadata_archive",
|
||||
"backup",
|
||||
"filesystem",
|
||||
"custom_words",
|
||||
"wildcards",
|
||||
"supporters",
|
||||
"doctor",
|
||||
"example_workflows",
|
||||
"base_model",
|
||||
"price_alerts",
|
||||
"model_source_handler",
|
||||
"agent_handler",
|
||||
"download_routing",
|
||||
"sidecar_migration",
|
||||
)
|
||||
handlers = {name: _AnyHandler() for name in names}
|
||||
handlers.update(overrides)
|
||||
return MiscHandlerSet(**handlers)
|
||||
|
||||
|
||||
def test_every_misc_route_definition_resolves_to_a_handler():
|
||||
"""A route added to the table without a mapping entry 500s only on a live
|
||||
server, so assert the whole table resolves here."""
|
||||
|
||||
mapping = _stub_misc_handler_set().to_route_mapping()
|
||||
|
||||
assert [
|
||||
definition.handler_name
|
||||
for definition in MISC_ROUTE_DEFINITIONS
|
||||
if definition.handler_name not in mapping
|
||||
] == []
|
||||
|
||||
|
||||
def test_price_alerts_route_is_registered_once_app_wide():
|
||||
definitions = [
|
||||
definition
|
||||
for definition in MISC_ROUTE_DEFINITIONS
|
||||
if definition.handler_name == "get_price_alerts"
|
||||
]
|
||||
|
||||
# Exactly one entry, and no {prefix}: the panel spans every model type.
|
||||
assert len(definitions) == 1
|
||||
assert definitions[0].method == "GET"
|
||||
assert definitions[0].path == "/api/lm/price-alerts"
|
||||
|
||||
|
||||
def _price_alerts_adapter(update_service, scanners=None):
|
||||
async def _unused_scanner():
|
||||
raise AssertionError("scanner should not be requested in this test")
|
||||
|
||||
async def _get_scanner():
|
||||
return scanners
|
||||
|
||||
return ServiceRegistryAdapter(
|
||||
get_lora_scanner=_get_scanner,
|
||||
get_checkpoint_scanner=_unused_scanner,
|
||||
get_embedding_scanner=_unused_scanner,
|
||||
get_downloaded_version_history_service=_unused_scanner,
|
||||
get_model_update_service=AsyncMock(return_value=update_service),
|
||||
)
|
||||
|
||||
|
||||
class _FakeUpdateService:
|
||||
def __init__(self, alerts):
|
||||
self.alerts = alerts
|
||||
self.calls = []
|
||||
|
||||
async def get_price_alerts(self, model_type=None, *, threshold_buzz=None, limit=200):
|
||||
self.calls.append((model_type, threshold_buzz, limit))
|
||||
return [dict(alert) for alert in self.alerts]
|
||||
|
||||
def newest_price_checked_at(self):
|
||||
return 1791039694.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_alerts_handler_returns_the_global_list():
|
||||
update_service = _FakeUpdateService(
|
||||
[
|
||||
{
|
||||
"modelId": 2981320,
|
||||
"modelType": "checkpoint",
|
||||
"versionId": 3379626,
|
||||
"kind": "below_threshold",
|
||||
"priceBuzz": 250,
|
||||
}
|
||||
]
|
||||
)
|
||||
settings = DummySettings(
|
||||
{
|
||||
"price_tracking_enabled": True,
|
||||
"price_alert_threshold_buzz": 300,
|
||||
"civitai_host": "civitai.com",
|
||||
}
|
||||
)
|
||||
handler = PriceAlertsHandler(
|
||||
settings_service=settings,
|
||||
service_registry=_price_alerts_adapter(update_service),
|
||||
)
|
||||
|
||||
response = await handler.get_price_alerts(
|
||||
FakeRequest(method="GET", query={"limit": "50"}) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["enabled"] is True
|
||||
assert payload["thresholdBuzz"] == 300
|
||||
assert payload["newestCheckedAt"] == 1791039694.5
|
||||
assert payload["alerts"][0]["civitaiUrl"] == (
|
||||
"https://civitai.com/models/2981320?modelVersionId=3379626"
|
||||
)
|
||||
# All model types in one call, with the threshold resolved from settings.
|
||||
assert update_service.calls == [(None, 300, 50)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_alerts_handler_decorates_local_context():
|
||||
update_service = _FakeUpdateService(
|
||||
[
|
||||
{
|
||||
"modelId": 1,
|
||||
"modelType": "lora",
|
||||
"versionId": 12,
|
||||
"kind": "below_threshold",
|
||||
"priceBuzz": 250,
|
||||
}
|
||||
]
|
||||
)
|
||||
cache = SimpleNamespace(
|
||||
model_id_index={1: [{"model_name": "Glorious Art", "file_name": "glorious.safetensors"}]},
|
||||
version_index={12: {"file_path": "/models/loras/glorious.safetensors", "file_name": "glorious.safetensors"}},
|
||||
)
|
||||
|
||||
class _Scanner:
|
||||
async def get_cached_data(self):
|
||||
return cache
|
||||
|
||||
async def _get_scanner():
|
||||
return _Scanner()
|
||||
|
||||
adapter = ServiceRegistryAdapter(
|
||||
get_lora_scanner=_get_scanner,
|
||||
get_checkpoint_scanner=_get_scanner,
|
||||
get_embedding_scanner=_get_scanner,
|
||||
get_downloaded_version_history_service=_get_scanner,
|
||||
get_model_update_service=AsyncMock(return_value=update_service),
|
||||
)
|
||||
handler = PriceAlertsHandler(
|
||||
settings_service=DummySettings({"civitai_host": "civitai.red"}),
|
||||
service_registry=adapter,
|
||||
)
|
||||
|
||||
response = await handler.get_price_alerts(
|
||||
FakeRequest(method="GET") # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
alert = _json_payload(response)["alerts"][0]
|
||||
|
||||
assert alert["modelName"] == "Glorious Art"
|
||||
assert alert["filePath"] == "/models/loras/glorious.safetensors"
|
||||
assert alert["civitaiUrl"].startswith("https://civitai.red/models/1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_alerts_handler_survives_a_cold_scanner_cache():
|
||||
"""Local context is best-effort: the panel must still render."""
|
||||
|
||||
update_service = _FakeUpdateService(
|
||||
[{"modelId": 1, "modelType": "lora", "versionId": 12, "kind": "became_free"}]
|
||||
)
|
||||
|
||||
async def _broken_scanner():
|
||||
raise RuntimeError("scanner not ready")
|
||||
|
||||
adapter = ServiceRegistryAdapter(
|
||||
get_lora_scanner=_broken_scanner,
|
||||
get_checkpoint_scanner=_broken_scanner,
|
||||
get_embedding_scanner=_broken_scanner,
|
||||
get_downloaded_version_history_service=_broken_scanner,
|
||||
get_model_update_service=AsyncMock(return_value=update_service),
|
||||
)
|
||||
handler = PriceAlertsHandler(
|
||||
settings_service=DummySettings(), service_registry=adapter
|
||||
)
|
||||
|
||||
response = await handler.get_price_alerts(
|
||||
FakeRequest(method="GET") # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
# Tracking is off in this fixture, so the panel explains itself.
|
||||
assert payload["enabled"] is False
|
||||
assert "modelName" not in payload["alerts"][0]
|
||||
assert "filePath" not in payload["alerts"][0]
|
||||
|
||||
Reference in New Issue
Block a user