mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 17:35:33 -03:00
refactor(update)!: make obtainability a property of updates, not a surface
The owner could not tell from the UI what "Buzz Price Tracking" enabled, what the "Price alert threshold" number meant, or what "Price Alerts" was alerting about. That was not a copy problem: the implementation exposed our mechanism (a page scrape) and our SQL predicates as the user's concepts. Two concrete defects came from the same root: - the alert population included versions the user already owns (neither the event generator nor the query filtered on is_in_library; in the owner's library 28 of 52 gated versions were already downloaded, so most "alerts" were about files already on disk, which cannot become cheaper *for them*); - a threshold-filtered state list lived in a notification surface, so an empty panel had three indistinguishable causes and read as a broken feature. The information model is now the version plus ownership: cost is shown only where a decision exists. Owned -> nothing. Not owned and free -> nothing. Not owned and gated -> the price when it is known, `Paid` without a number when it is not, and early access keeps its countdown because "free on <date>" decides between waiting and paying. The numeric threshold has no place in that model: every decision is categorical (wait / pay / skip), so the setting, the comparison and the whole alert-state machine are gone. - both alert-state columns are removed from the schema rather than left dead; a database created by an unreleased build has them dropped on open (native ALTER TABLE ... DROP COLUMN, guarded), which is a no-op for everyone else - gate events are emitted only for versions the user does not have, and the price-drop event goes with the threshold it belonged to - both alert endpoints, PriceAlertsHandler and the service-registry adapter field it needed are removed: events already reach the UI through the refresh response - the bell tab, panel, CSS, both entry points, the unread watermark and their locales are removed; the setting keeps only the enable flag and the refresh interval and is framed as plumbing - "Price unavailable" is replaced by `Paid`: the gate is certain from the public API, only the number is best-effort, and that is our plumbing, not the user's problem Verified against a copy of the owner's real database: 52 gated versions -> 28 owned (now silent) + 24 the feature is actually about; the drop migration ran and both removed endpoints 404.
This commit is contained in:
@@ -17,7 +17,6 @@ from py.routes.handlers.misc_handlers import (
|
||||
BackupHandler,
|
||||
DoctorHandler,
|
||||
MiscHandlerSet,
|
||||
PriceAlertsHandler,
|
||||
FileSystemHandler,
|
||||
HealthCheckHandler,
|
||||
LoraCodeHandler,
|
||||
@@ -2896,7 +2895,6 @@ def _stub_misc_handler_set(**overrides) -> MiscHandlerSet:
|
||||
"doctor",
|
||||
"example_workflows",
|
||||
"base_model",
|
||||
"price_alerts",
|
||||
"model_source_handler",
|
||||
"agent_handler",
|
||||
"download_routing",
|
||||
@@ -2920,177 +2918,14 @@ def test_every_misc_route_definition_resolves_to_a_handler():
|
||||
] == []
|
||||
|
||||
|
||||
def test_price_alerts_route_is_registered_once_app_wide():
|
||||
definitions = [
|
||||
def test_price_alert_endpoints_are_gone():
|
||||
"""The redesign removed the standalone alerts surface: obtainability is an
|
||||
attribute of the update surfaces, so no route may serve an alert list."""
|
||||
|
||||
leftovers = [
|
||||
definition
|
||||
for definition in MISC_ROUTE_DEFINITIONS
|
||||
if definition.handler_name == "get_price_alerts"
|
||||
if "price-alert" in definition.path or "price_alert" in definition.handler_name
|
||||
]
|
||||
|
||||
# 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, *, unavailable_count=0, priced_count=0):
|
||||
self.alerts = alerts
|
||||
self.calls = []
|
||||
self.unavailable_count = unavailable_count
|
||||
self.priced_count = priced_count
|
||||
|
||||
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
|
||||
|
||||
def count_unavailable_prices(self, model_type=None):
|
||||
return self.unavailable_count
|
||||
|
||||
def count_priced_versions(self, model_type=None):
|
||||
return self.priced_count
|
||||
|
||||
|
||||
@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["unavailableCount"] == 0
|
||||
assert payload["pricedCount"] == 0
|
||||
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]
|
||||
assert leftovers == []
|
||||
|
||||
Reference in New Issue
Block a user