diff --git a/__init__.py b/__init__.py index fba856324..c484bef6e 100644 --- a/__init__.py +++ b/__init__.py @@ -2583,11 +2583,13 @@ class BlenderKitAddonPreferences(AddonPreferences): send_usage_data: BoolProperty( name="Send usage data to improve Blendkit", description=( - "Report which Blendkit assets are in your file when you save or render " - "(asset ids and counts only, no file names or scene content). Helps rank " - "search results by what people actually use and, in the future, reward " - "creators for assets that get used. The choice is stored in Blendkit-Client " - "and shared by every Blendkit add-on on this machine" + "Send anonymous usage data: which Blendkit assets are in your file when " + "you save or render (asset ids and counts only, no file names or scene " + "content), when a sign-in prompt is shown or used, and when you try a " + "locked asset (its id). Helps rank search results by what people actually " + "use, reward creators for assets that get used, and improve the add-on. " + "Never affects downloads, search or sign-in. The choice is stored in " + "Blendkit-Client and shared by every Blendkit add-on on this machine" ), default=True, update=utils.send_usage_data_updated, diff --git a/asset_bar/asset_drag_op.py b/asset_bar/asset_drag_op.py index 827f8d8b6..8bafa9392 100644 --- a/asset_bar/asset_drag_op.py +++ b/asset_bar/asset_drag_op.py @@ -2173,6 +2173,9 @@ def invoke(self, context, event): ): message = "This addon is not purchased yet." link_text = "Purchase add-on online" + unlock_options.report_locked_asset_click( + self.asset_data, "addon_purchase_drag", None + ) url = paths.get_unlock_asset_url( self.asset_data["id"], "addon_purchase_drag" ) @@ -2186,6 +2189,9 @@ def invoke(self, context, event): if not self.asset_data.get("canDownload"): variant = unlock_options.get_unlock_variant() + unlock_options.report_locked_asset_click( + self.asset_data, "asset_unlock_drag", variant.identifier + ) url = paths.get_unlock_asset_url( self.asset_data["id"], "asset_unlock_drag", variant.identifier ) diff --git a/client_lib.py b/client_lib.py index ec51eb154..c86ddfd9f 100644 --- a/client_lib.py +++ b/client_lib.py @@ -467,12 +467,20 @@ def handle_settings_task(task) -> None: applying_client_settings = False +def send_usage_data_enabled() -> bool: + """The "Send usage data" preference; main thread only (reads bpy.context).""" + return bool(bpy.context.preferences.addons[__package__].preferences.send_usage_data) + + def report_event(event: str, data: Optional[dict] = None) -> None: """Fire-and-forget telemetry event (e.g. login funnel) via Blendkit-Client. The Client forwards it to the server with standard headers in the background - and surfaces nothing to the UI. + and surfaces nothing to the UI. Skipped when the user opted out of sending + usage data; the Client drops the event too, this only saves the request. """ + if not send_usage_data_enabled(): + return payload = ensure_minimal_data({"event": event, "data": data or {}}) try: with requests.Session() as session: diff --git a/download.py b/download.py index 498c6eb25..e9712d16a 100644 --- a/download.py +++ b/download.py @@ -460,7 +460,7 @@ def handle_usage_report_task(task: client_tasks.Task) -> None: def usage_reports_enabled() -> bool: - return bool(bpy.context.preferences.addons[__package__].preferences.send_usage_data) + return client_lib.send_usage_data_enabled() @persistent diff --git a/tests/test_client_lib.py b/tests/test_client_lib.py index 01c87f626..444409e78 100644 --- a/tests/test_client_lib.py +++ b/tests/test_client_lib.py @@ -613,6 +613,22 @@ def setUp(self): self._saved_version = global_vars.CLIENT_VERSION global_vars.CLIENT_PORTS = ["62485"] global_vars.CLIENT_VERSION = "v1.12.0" + enabled = mock.patch.object( + client_lib, "send_usage_data_enabled", return_value=True + ) + enabled.start() + self.addCleanup(enabled.stop) + + def test_opted_out_sends_nothing(self): + with ( + mock.patch.object( + client_lib, "send_usage_data_enabled", return_value=False + ), + mock.patch.object(client_lib.requests, "Session") as session_cls, + ): + client_lib.report_event("login_started", {"placement": "login_panel"}) + + session_cls.assert_not_called() def tearDown(self): global_vars.CLIENT_PORTS = self._saved_ports diff --git a/tests/test_ui_panels.py b/tests/test_ui_panels.py index 3a02af72b..4d9500891 100644 --- a/tests/test_ui_panels.py +++ b/tests/test_ui_panels.py @@ -355,3 +355,42 @@ def test_no_checkbox_for_non_validators(self): self.assertNotIn( ("prop", "new_comment_is_validation"), self.draw(0, is_validator=False) ) + + +class TestUnlockAssetOperator(unittest.TestCase): + def test_reports_click_then_opens_tagged_url(self): + with ( + patch.object( + ui_panels.unlock_options, "report_locked_asset_click" + ) as report, + patch.object(ui_panels, "_open_url") as open_url, + ): + result = bpy.ops.wm.blenderkit_unlock_asset( + asset_id="ver-1", + asset_base_id="base-1", + asset_type="model", + variant="join", + ) + + self.assertEqual(result, {"FINISHED"}) + report.assert_called_once_with( + {"id": "ver-1", "assetBaseId": "base-1", "assetType": "model"}, + "asset_unlock_panel", + "join", + ) + url = open_url.call_args.args[0] + self.assertIn("/get-blenderkit/ver-1/?from_addon=True", url) + self.assertIn("utm_content=asset_unlock_panel", url) + self.assertIn("ab_variant=join", url) + + def test_empty_variant_is_not_sent(self): + with ( + patch.object( + ui_panels.unlock_options, "report_locked_asset_click" + ) as report, + patch.object(ui_panels, "_open_url") as open_url, + ): + bpy.ops.wm.blenderkit_unlock_asset(asset_id="ver-1") + + self.assertIsNone(report.call_args.args[2]) + self.assertNotIn("ab_variant", open_url.call_args.args[0]) diff --git a/tests/test_unlock_options.py b/tests/test_unlock_options.py new file mode 100644 index 000000000..8d3aa7692 --- /dev/null +++ b/tests/test_unlock_options.py @@ -0,0 +1,43 @@ +import unittest +from unittest import mock + +# ``test.py`` imports this as ``.tests.``; strip ``.tests`` so the +# relative import resolves against the add-on package. +if __package__: + __package__ = __package__.rsplit(".tests", 1)[0] +from . import unlock_options + + +class TestReportLockedAssetClick(unittest.TestCase): + def test_reports_asset_ids_placement_and_variant(self): + asset_data = {"id": "ver-1", "assetBaseId": "base-1", "assetType": "model"} + with mock.patch.object( + unlock_options.client_lib, "report_event" + ) as report_event: + unlock_options.report_locked_asset_click( + asset_data, "asset_unlock_drag", "join" + ) + + report_event.assert_called_once_with( + "locked_asset_clicked", + { + "asset_base_id": "base-1", + "asset_id": "ver-1", + "asset_type": "model", + "placement": "asset_unlock_drag", + "variant": "join", + }, + ) + + def test_missing_keys_and_variant_become_none(self): + with mock.patch.object( + unlock_options.client_lib, "report_event" + ) as report_event: + unlock_options.report_locked_asset_click( + {"id": "ver-2"}, "addon_purchase_drag", None + ) + + payload = report_event.call_args.args[1] + self.assertIsNone(payload["asset_base_id"]) + self.assertIsNone(payload["asset_type"]) + self.assertIsNone(payload["variant"]) diff --git a/ui_panels.py b/ui_panels.py index 2e9e0f2e2..48b50ef2b 100644 --- a/ui_panels.py +++ b/ui_panels.py @@ -58,6 +58,7 @@ ratings_utils, search, ui, + unlock_options, upload, utils, ) @@ -3806,11 +3807,12 @@ def draw_thumbnail_box(self, layout, width=250): else: variant = unlock_options.get_unlock_variant() op = layout.operator( - "wm.blenderkit_url", text=variant.button_text, icon="UNLOCKED" - ) - op.url = paths.get_unlock_asset_url( - self.asset_data["id"], "asset_unlock_panel", variant.identifier + "wm.blenderkit_unlock_asset", text=variant.button_text, icon="UNLOCKED" ) + op.asset_id = self.asset_data["id"] + op.asset_base_id = self.asset_data.get("assetBaseId", "") + op.asset_type = self.asset_data.get("assetType", "") + op.variant = variant.identifier def draw_menu_desc_author(self, context, layout, width=330): box = layout.column() @@ -4885,7 +4887,42 @@ def invoke(self, context, event): return context.window_manager.invoke_props_dialog(self, width=400) +def _open_url(url: str) -> None: + bpy.ops.wm.url_open(url=url) + + +class UnlockAssetOnline(bpy.types.Operator): + """Open the unlock page for this locked asset""" + + bl_idname = "wm.blenderkit_unlock_asset" + bl_label = "Unlock asset" + bl_options = {"REGISTER", "INTERNAL"} + + asset_id: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"}) # type: ignore[valid-type] + asset_base_id: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"}) # type: ignore[valid-type] + asset_type: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"}) # type: ignore[valid-type] + placement: bpy.props.StringProperty( # type: ignore[valid-type] + default="asset_unlock_panel", options={"SKIP_SAVE", "HIDDEN"} + ) + variant: bpy.props.StringProperty(options={"SKIP_SAVE", "HIDDEN"}) # type: ignore[valid-type] + + def execute(self, context): + variant_id = self.variant or None + unlock_options.report_locked_asset_click( + { + "id": self.asset_id, + "assetBaseId": self.asset_base_id, + "assetType": self.asset_type, + }, + self.placement, + variant_id, + ) + _open_url(paths.get_unlock_asset_url(self.asset_id, self.placement, variant_id)) + return {"FINISHED"} + + classes = ( + UnlockAssetOnline, BLENDERKIT_OT_hdr_thumbnail_tune, BLENDERKIT_OT_show_validation_popup, BLENDERKIT_OT_permissions_error_popup, diff --git a/unlock_options.py b/unlock_options.py index a88d56d7d..3be40b34d 100644 --- a/unlock_options.py +++ b/unlock_options.py @@ -32,8 +32,35 @@ import random from dataclasses import dataclass +from . import client_lib + bk_logger = logging.getLogger(__name__) +LOCKED_ASSET_EVENT = "locked_asset_clicked" + + +def report_locked_asset_click( + asset_data: dict, placement: str, variant_id: str | None +) -> None: + """Telemetry for an attempt to use a locked asset (a drag, or the unlock button). + + Goes through Blendkit-Client's optional /report_event route, so it is covered by + the "Send usage data" preference. Asset ids make locked assets comparable by + demand and let the web side follow up on real interest; ``placement`` and + ``variant_id`` mirror the unlock link's UTM tags so encounters and click-throughs + share one vocabulary. + """ + client_lib.report_event( + LOCKED_ASSET_EVENT, + { + "asset_base_id": asset_data.get("assetBaseId"), + "asset_id": asset_data.get("id"), + "asset_type": asset_data.get("assetType"), + "placement": placement, + "variant": variant_id, + }, + ) + @dataclass(frozen=True) class UnlockVariant: