Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions asset_bar/asset_drag_op.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
)
Expand Down
10 changes: 9 additions & 1 deletion client_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tests/test_client_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions tests/test_ui_panels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
43 changes: 43 additions & 0 deletions tests/test_unlock_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import unittest
from unittest import mock

# ``test.py`` imports this as ``<addon>.tests.<name>``; 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"])
45 changes: 41 additions & 4 deletions ui_panels.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
ratings_utils,
search,
ui,
unlock_options,
upload,
utils,
)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions unlock_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading