diff --git a/bk_client b/bk_client index 3d153ceb8..e01a2d32a 160000 --- a/bk_client +++ b/bk_client @@ -1 +1 @@ -Subproject commit 3d153ceb8b687d2c873a0c4a0a177e52f5214b9b +Subproject commit e01a2d32ac244f5ad356ff4c124e5a7ab3249587 diff --git a/client_lib.py b/client_lib.py index 9a8e942c4..91d288ef8 100644 --- a/client_lib.py +++ b/client_lib.py @@ -475,6 +475,58 @@ def send_rating(asset_id: str, rating_type: str, rating_value: Union[str, int]): ) +def get_not_used_reasons(): + """Fetch the shared "didn't use it" reason choices.""" + data = ensure_minimal_data() + with requests.Session() as session: + return session.get( + f"{get_base_url()}/ratings/get_not_used_reasons", + json=data, + timeout=TIMEOUT, + proxies=NO_PROXIES, + ) + + +def get_didnt_use(asset_id: str): + """Fetch the user's "I didn't use this asset" flag for one asset.""" + data = ensure_minimal_data({"asset_id": asset_id}) + with requests.Session() as session: + return session.get( + f"{get_base_url()}/ratings/get_didnt_use", + json=data, + timeout=TIMEOUT, + proxies=NO_PROXIES, + ) + + +def send_didnt_use( + asset_id: str, + didnt_use: bool, + reason_id: Optional[int] = None, + replace_rating: bool = False, +): + """Set (with an optional reason) or clear the "I didn't use this asset" flag. + + replace_rating deletes the user's score ratings server-side instead of the + 409 refusal - send it only from UI that warned about the replacement. + """ + data = ensure_minimal_data( + { + "asset_id": asset_id, + "didnt_use": didnt_use, + "reason_id": reason_id, + "replace_rating": replace_rating, + } + ) + with requests.Session() as session: + return session.post( + f"{get_base_url()}/ratings/send_didnt_use", + json=data, + timeout=TIMEOUT, + proxies=NO_PROXIES, + ) + + # BOOKMARKS def get_bookmarks(): data = ensure_minimal_data() diff --git a/datas.py b/datas.py index a5fe59abb..b2f258c9f 100644 --- a/datas.py +++ b/datas.py @@ -196,4 +196,17 @@ class AssetRating(FromDictMixin): quality_fetched: bool = False working_hours: Optional[float] = None # name kept as comes from API working_hours_fetched: bool = False + # The "I didn't use this asset" feedback flag; mutually exclusive with + # quality/working_hours server-side (rating wins). + didnt_use: bool = False + didnt_use_reason: Optional[str] = None + didnt_use_reason_id: Optional[int] = None + didnt_use_fetched: bool = False + # Session-local memory of the scores a "didn't use" pick replaced, so + # undo restores the numbers, not just the unflagged state. + didnt_use_replaced_quality: Optional[float] = None + didnt_use_replaced_working_hours: Optional[float] = None + # The last "didn't use" server refusal, shown inline under the control - + # the corner report overlay is out of sight of the rating popup. + didnt_use_error: Optional[str] = None # TODO: Add last time ratings checked to improve caching diff --git a/global_vars.py b/global_vars.py index 48d6600e7..6ff412262 100644 --- a/global_vars.py +++ b/global_vars.py @@ -63,6 +63,11 @@ } RATINGS: dict[str, datas.AssetRating] = {} + +# Shared "didn't use it" reason choices fetched from the server +# (None = not fetched yet, [] = fetch requested/failed - flag still works +# without a reason). +NOT_USED_REASONS: Optional[list[dict]] = None BKIT_PROFILE: datas.MineProfile = datas.MineProfile() """Profile of the current user.""" BKIT_AUTHORS: dict[int, datas.UserProfile] = {} diff --git a/ratings.py b/ratings.py index 6d8625db0..96f773902 100644 --- a/ratings.py +++ b/ratings.py @@ -19,7 +19,7 @@ import logging import bpy -from bpy.props import BoolProperty, StringProperty +from bpy.props import BoolProperty, IntProperty, StringProperty from bpy.types import Gizmo, GizmoGroup, Operator from bpy_extras import view3d_utils from mathutils import Matrix @@ -125,6 +125,18 @@ def get_assets_for_rating(): ) +def _flagged_hint(rating) -> str: + """What the flag replaced, so undo's promise is concrete.""" + replaced = [] + if rating.didnt_use_replaced_quality: + replaced.append(f"{rating.didnt_use_replaced_quality:g}\u2605") + if rating.didnt_use_replaced_working_hours: + replaced.append(f"{rating.didnt_use_replaced_working_hours:g} h") + if replaced: + return f"Your {' / '.join(replaced)} rating was cleared - Undo restores it" + return "Marked as not used - undo below to rate" + + def draw_ratings_menu(self, context, layout): pcoll = icons.icon_collections["main"] @@ -156,6 +168,18 @@ def draw_ratings_menu(self, context, layout): if profile and len(profile.firstName) > 0: profile_name = " " + profile.firstName + # Mutual exclusivity, drawn not explained: a flagged asset's rating + # controls are disabled (the server would refuse the score anyway - + # flag and rating can never both exist). + rating_state = ratings_utils.get_rating_local(self.asset_id) + flagged = rating_state is not None and rating_state.didnt_use + outer = col + if flagged: + row.label(text=_flagged_hint(rating_state), icon="INFO") + col = col.column() + col.enabled = not flagged + row = col.row() + row.label(text="Rate Quality:", icon="SOLO_ON") # row = col.row() # row.label(text='Please help the community by rating quality:') @@ -203,6 +227,40 @@ def draw_ratings_menu(self, context, layout): row = col.row() row.label(text=f"Thanks{profile_name}, you are amazing!", icon="FUND") + draw_didnt_use_control(outer, self.asset_id) + + +# The NotUsedMenu target: Blender menus draw without arguments, so the last +# ratings UI drawn for an asset parks its id here for the menu to read. +active_not_used_asset_id = "" + + +def draw_didnt_use_control(layout, asset_id): + """The "Didn't use it" flag control: one dropdown for every state, + like on the My downloads page. Never disabled: on a rated asset the menu + warns that a pick replaces the rating, and undo restores it.""" + global active_not_used_asset_id + active_not_used_asset_id = asset_id + ratings_utils.ensure_not_used_reasons() + ratings_utils.ensure_didnt_use(asset_id) + + rating = ratings_utils.get_rating_local(asset_id) + flagged = rating is not None and rating.didnt_use + + layout.separator() + row = layout.row() + if flagged: + text = rating.didnt_use_reason or "I didn't use this asset" + row.menu(NotUsedMenu.bl_idname, text=text, icon="CHECKMARK") + else: + row.menu(NotUsedMenu.bl_idname, text="I didn't use this asset") + if rating is not None and rating.didnt_use_error: + # The refusal right under the control that caused it - the corner + # report overlay is out of sight of this popup. + error_row = layout.row() + error_row.alert = True + error_row.label(text=rating.didnt_use_error, icon="ERROR") + class FastRateMenu(Operator, ratings_utils.RatingProperties): """Rating of the assets, also directly from the asset bar - without need to download assets""" @@ -252,6 +310,13 @@ def execute(self, context): self.asset_type = self.asset_data["assetType"] elif ui_props.active_index > -1: sr = search.get_search_results() + if ui_props.active_index >= len(sr): + bk_logger.warning( + "FastRateMenu: active_index %d out of bounds for search results of length %d", + ui_props.active_index, + len(sr), + ) + return {"CANCELLED"} self.asset_data = dict(sr[ui_props.active_index]) self.asset_id = self.asset_data["id"] self.asset_type = self.asset_data["assetType"] @@ -479,9 +544,106 @@ def refresh(self, context): gz.matrix_basis = Matrix.Translation(loc) @ R @ Matrix.Diagonal(scale.to_4d()) +class SetNotUsed(bpy.types.Operator): + """Mark the asset as one you did not use, or undo that.\nMutually exclusive with rating - the flag is refused while your rating stands""" + + bl_idname = "wm.blenderkit_not_used" + bl_label = "I didn't use this asset" + bl_options = {"REGISTER", "INTERNAL"} + + asset_id: StringProperty( # type: ignore[valid-type] + name="Asset Base Id", + description="Unique id of the asset (hidden)", + default="", + options={"SKIP_SAVE"}, + ) + reason_id: IntProperty( # type: ignore[valid-type] + name="Reason", + description="Server id of the picked reason; -1 means no particular reason", + default=-1, + options={"SKIP_SAVE"}, + ) + undo: BoolProperty( # type: ignore[valid-type] + name="Undo", + description="Clear the flag - I did use it after all", + default=False, + options={"SKIP_SAVE"}, + ) + + def execute(self, context): + ratings_utils.store_didnt_use_error(self.asset_id, None) + if self.undo: + # A pick that replaced a rating undoes through the rating API - + # re-rating clears the flag server-side, numbers included. + if not ratings_utils.restore_replaced_scores(self.asset_id): + client_lib.send_didnt_use(self.asset_id, False) + else: + reason_id = self.reason_id if self.reason_id >= 0 else None + # Consent came from the menu: its header warns "This replaces + # your rating" whenever a rating stands. + ratings_utils.remember_replaced_scores(self.asset_id) + client_lib.send_didnt_use( + self.asset_id, True, reason_id, replace_rating=True + ) + return {"FINISHED"} + + +class NotUsedMenu(bpy.types.Menu): + """Reason picker for the "Didn't use it" flag - one click saves, + picking another reason just changes it, undo lives at the bottom.""" + + bl_idname = "OBJECT_MT_blenderkit_not_used" + bl_label = "Why didn't you use it?" + + def draw(self, context): + layout = self.layout + asset_id = active_not_used_asset_id + rating = ratings_utils.get_rating_local(asset_id) + flagged = rating is not None and rating.didnt_use + rated = rating is not None and ( + bool(rating.quality) or bool(rating.working_hours) + ) + current_reason_id = rating.didnt_use_reason_id if flagged else None + + if rated and not flagged: + # The consequence, stated where the eyes already are - picking a + # reason below replaces the rating (undo restores it). + layout.label(text="This replaces your rating", icon="INFO") + layout.separator() + + op = layout.operator( + SetNotUsed.bl_idname, + text="No particular reason", + icon="CHECKMARK" if flagged and current_reason_id is None else "NONE", + ) + op.asset_id = asset_id + for reason in global_vars.NOT_USED_REASONS or []: + op = layout.operator( + SetNotUsed.bl_idname, + text=reason["label"], + icon="CHECKMARK" if reason["id"] == current_reason_id else "NONE", + ) + op.asset_id = asset_id + op.reason_id = reason["id"] + if flagged: + layout.separator() + has_memory = bool( + rating.didnt_use_replaced_quality + or rating.didnt_use_replaced_working_hours + ) + undo_text = ( + "Undo - restore my rating" if has_memory else "Undo - I did use it" + ) + op = layout.operator(SetNotUsed.bl_idname, text=undo_text, icon="X") + op.asset_id = asset_id + op.undo = True + + classes = ( FastRateMenu, SetBookmark, + SetNotUsed, + NotUsedMenu, RatingStarWidget, RatingStarWidgetGroup, ratings_utils.RatingProperties, diff --git a/ratings_utils.py b/ratings_utils.py index 85f31bef5..e9496aa99 100644 --- a/ratings_utils.py +++ b/ratings_utils.py @@ -17,6 +17,7 @@ # ##### END GPL LICENSE BLOCK ##### import logging +import re from typing import Optional, Union # mainly update functions and callbacks for ratings properties, here to avoid circular imports. @@ -60,6 +61,12 @@ def handle_get_rating_task(task: client_tasks.Task): return for rating in ratings: + # The API returns every user-editable rating, including historic + # vote types (competition, nodevember) this UI has no controls for - + # skip those instead of crashing the task-handling timer. + if rating["ratingType"] not in ("quality", "working_hours", "bookmarks"): + bk_logger.debug("Ignoring rating of type %s", rating["ratingType"]) + continue store_rating_local(asset_id, rating["ratingType"], rating["score"]) @@ -97,10 +104,154 @@ def handle_send_rating_task(task: client_tasks.Task): task.message, type="ERROR", details=task.message_detailed ) if task.status == "finished": + # Rating a flagged asset drops its "didn't use" flag server-side + # (rating wins) - mirror that locally so the menu doesn't lie. + data = task.data + if data.get("rating_type") in ("quality", "working_hours") and data.get( + "rating_value" + ): + rating = get_rating_local(data["asset_id"]) + if rating is not None and rating.didnt_use: + store_didnt_use_local(data["asset_id"], didnt_use=False) if utils.profile_is_validator(): return reports.add_report(task.message, type="VALIDATOR") +def handle_get_not_used_reasons_task(task: client_tasks.Task): + """Cache the shared "didn't use it" reason choices in global_vars.""" + if task.status == "created": + return + if task.status == "error": + return bk_logger.warning("%s task failed: %s", task.task_type, task.message) + global_vars.NOT_USED_REASONS = task.result["results"] + + +def handle_get_didnt_use_task(task: client_tasks.Task): + """Save the asset's "didn't use" flag state into the local ratings store.""" + if task.status == "created": + return + if task.status == "error": + return bk_logger.warning("%s task failed: %s", task.task_type, task.message) + reason = task.result.get("reason") or {} + store_didnt_use_local( + task.data["asset_id"], + didnt_use=task.result["didntUse"], + reason_label=reason.get("label"), + reason_id=reason.get("id"), + ) + + +def handle_send_didnt_use_task(task: client_tasks.Task): + """Apply the server-confirmed flag state; a refusal lands inline under + the control (the corner report overlay is out of the popup's sight).""" + if task.status == "created": + return + if task.status == "error": + store_didnt_use_error(task.data["asset_id"], _server_detail(task.message)) + return reports.add_report( + task.message, type="ERROR", details=task.message_detailed + ) + reason = task.result.get("reason") or {} + store_didnt_use_local( + task.data["asset_id"], + didnt_use=task.result["didntUse"], + reason_label=reason.get("label"), + reason_id=reason.get("id"), + ) + reports.add_report(task.message) + + +def store_didnt_use_local( + asset_id: str, + didnt_use: bool, + reason_label: Optional[str] = None, + reason_id: Optional[int] = None, +): + rating = global_vars.RATINGS.get(asset_id, datas.AssetRating()) + rating.didnt_use = didnt_use + rating.didnt_use_reason = reason_label + rating.didnt_use_reason_id = reason_id + rating.didnt_use_fetched = True + rating.didnt_use_error = None + if didnt_use: + # The server deleted the score ratings (flag and rating are mutually + # exclusive) - the local mirror must agree. + rating.quality = None + rating.working_hours = None + global_vars.RATINGS[asset_id] = rating + + +def _server_detail(message: str) -> str: + """The server's own sentence out of the Client's error wrapper + ("send didnt-use: (403 Forbidden)"). + + Older Clients pass the raw JSON body through - pull the detail field out + rather than showing braces to the user.""" + json_detail = re.search(r'"detail"\s*:\s*"([^"]+)"', message or "") + if json_detail: + return json_detail.group(1) + detail = re.sub(r"^send didnt-use: ", "", message or "") + detail = re.sub(r" \(\d{3} [^)]*\)$", "", detail) + return detail or "That didn't save - please try again." + + +def store_didnt_use_error(asset_id: str, message: Optional[str]): + rating = global_vars.RATINGS.get(asset_id, datas.AssetRating()) + rating.didnt_use_error = message + global_vars.RATINGS[asset_id] = rating + + +def remember_replaced_scores(asset_id: str): + """Park the current scores on the rating before a "didn't use" pick + replaces them - session-local, so undo can restore the numbers.""" + rating = global_vars.RATINGS.get(asset_id) + if rating is None: + return + if rating.quality or rating.working_hours: + rating.didnt_use_replaced_quality = rating.quality + rating.didnt_use_replaced_working_hours = rating.working_hours + + +def restore_replaced_scores(asset_id: str) -> bool: + """Undo the rating half of a replacement: re-send the remembered scores + (the server clears the flag itself - rating wins) and mirror locally. + Returns False when there is nothing remembered.""" + rating = global_vars.RATINGS.get(asset_id) + if rating is None: + return False + remembered = [ + ("quality", rating.didnt_use_replaced_quality), + ("working_hours", rating.didnt_use_replaced_working_hours), + ] + if not any(value for _, value in remembered): + return False + for slug, value in remembered: + if not value: + continue + client_lib.send_rating(asset_id, slug, value) + setattr(rating, slug, value) + rating.didnt_use = False + rating.didnt_use_reason = None + rating.didnt_use_reason_id = None + rating.didnt_use_replaced_quality = None + rating.didnt_use_replaced_working_hours = None + return True + + +def ensure_not_used_reasons(): + """Fetch the reason choices once per session; [] marks the request as + made so a failure doesn't retrigger on every redraw.""" + if global_vars.NOT_USED_REASONS is None: + global_vars.NOT_USED_REASONS = [] + client_lib.get_not_used_reasons() + + +def ensure_didnt_use(asset_id: str): + rating = get_rating_local(asset_id) + if rating is None or not rating.didnt_use_fetched: + client_lib.get_didnt_use(asset_id) + + def store_rating_local( asset_id: str, rating_type: str = "quality", value: Optional[int] = None ): diff --git a/tests/test.py b/tests/test.py index 4dea27185..93b5ae694 100644 --- a/tests/test.py +++ b/tests/test.py @@ -99,6 +99,7 @@ "test_persistent_preferences", "test_timer", "test_rating_nudge", + "test_didnt_use", "test_ratings", "test_keymap_utils", "test_override_extension_draw", diff --git a/tests/test_didnt_use.py b/tests/test_didnt_use.py new file mode 100644 index 000000000..8af982fe1 --- /dev/null +++ b/tests/test_didnt_use.py @@ -0,0 +1,318 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +import types +import unittest +from unittest import mock + +if __package__: + __package__ = __package__.rsplit(".tests", 1)[0] +from . import client_tasks, datas, global_vars, ratings, ratings_utils, timer + + +def make_task(task_type, status="finished", data=None, result=None): + return client_tasks.Task( + data=data if data is not None else {}, + app_id="app", + task_type=task_type, + status=status, + result=result if result is not None else {}, + ) + + +class DidntUseStateTest(unittest.TestCase): + """Ratings store keeps the "didn't use" flag next to the scores.""" + + def setUp(self): + self._orig_ratings = dict(global_vars.RATINGS) + self._orig_reasons = global_vars.NOT_USED_REASONS + global_vars.RATINGS.clear() + global_vars.NOT_USED_REASONS = None + + def tearDown(self): + global_vars.RATINGS.clear() + global_vars.RATINGS.update(self._orig_ratings) + global_vars.NOT_USED_REASONS = self._orig_reasons + + def test_get_rating_task_skips_unknown_vote_types(self): + # Devel/production carry historic vote types (competition-2022-votes, + # nodevember-votes); one of those in the response must not kill the + # timer tick - reproduce of the 2026-09-01 crash on devel. + task = make_task( + "ratings/get_rating", + data={"asset_id": "abc"}, + result={ + "results": [ + {"ratingType": "competition-2022-votes", "score": 1}, + {"ratingType": "quality", "score": 9}, + ] + }, + ) + ratings_utils.handle_get_rating_task(task) + self.assertEqual(global_vars.RATINGS["abc"].quality, 9) + + def test_get_didnt_use_task_stores_flag_and_reason(self): + task = make_task( + "ratings/get_didnt_use", + data={"asset_id": "abc"}, + result={"didntUse": True, "reason": {"id": 3, "label": "Just testing"}}, + ) + ratings_utils.handle_get_didnt_use_task(task) + rating = global_vars.RATINGS["abc"] + self.assertTrue(rating.didnt_use) + self.assertEqual(rating.didnt_use_reason, "Just testing") + self.assertEqual(rating.didnt_use_reason_id, 3) + self.assertTrue(rating.didnt_use_fetched) + + def test_get_didnt_use_task_stores_clear_state(self): + task = make_task( + "ratings/get_didnt_use", + data={"asset_id": "abc"}, + result={"didntUse": False, "reason": None}, + ) + ratings_utils.handle_get_didnt_use_task(task) + rating = global_vars.RATINGS["abc"] + self.assertFalse(rating.didnt_use) + self.assertIsNone(rating.didnt_use_reason) + self.assertTrue(rating.didnt_use_fetched) + + def test_send_didnt_use_task_error_lands_inline_and_in_reports(self): + task = make_task( + "ratings/send_didnt_use", status="error", data={"asset_id": "abc"} + ) + task.message = ( + "send didnt-use: Only downloaded assets can be flagged. (403 Forbidden)" + ) + with mock.patch.object(ratings_utils.reports, "add_report") as add_report: + ratings_utils.handle_send_didnt_use_task(task) + self.assertEqual(add_report.call_args.kwargs.get("type"), "ERROR") + # Inline copy: the server's sentence, without the Client's wrapper - + # drawn in red right under the control (the corner overlay is out of + # the popup's sight). + self.assertEqual( + global_vars.RATINGS["abc"].didnt_use_error, + "Only downloaded assets can be flagged.", + ) + + def test_raw_json_error_body_still_yields_the_sentence(self): + # Older Clients wrap the raw response body - the inline line must + # show the detail sentence, never braces (seen in Blender 2026-09-01). + task = make_task( + "ratings/send_didnt_use", status="error", data={"asset_id": "abc"} + ) + task.message = ( + 'send didnt-use: {"detail":"Only downloaded assets can be ' + 'flagged.","statusCode":403} (403 Forbidden)' + ) + with mock.patch.object(ratings_utils.reports, "add_report"): + ratings_utils.handle_send_didnt_use_task(task) + self.assertEqual( + global_vars.RATINGS["abc"].didnt_use_error, + "Only downloaded assets can be flagged.", + ) + + def test_unparsable_error_still_shows_something_inline(self): + task = make_task( + "ratings/send_didnt_use", status="error", data={"asset_id": "abc"} + ) + task.message = "" + with mock.patch.object(ratings_utils.reports, "add_report"): + ratings_utils.handle_send_didnt_use_task(task) + self.assertTrue(global_vars.RATINGS["abc"].didnt_use_error) + + def test_confirmed_state_clears_the_inline_error(self): + ratings_utils.store_didnt_use_error( + "abc", "Only downloaded assets can be flagged." + ) + task = make_task( + "ratings/send_didnt_use", + data={"asset_id": "abc"}, + result={"didntUse": True, "reason": None}, + ) + with mock.patch.object(ratings_utils.reports, "add_report"): + ratings_utils.handle_send_didnt_use_task(task) + self.assertIsNone(global_vars.RATINGS["abc"].didnt_use_error) + + def test_send_didnt_use_task_applies_confirmed_state(self): + # A confirmed flag also clears the local scores - the server deleted + # them (mutual exclusivity), so the mirror must agree. + global_vars.RATINGS["abc"] = datas.AssetRating(quality=8, working_hours=4) + task = make_task( + "ratings/send_didnt_use", + data={"asset_id": "abc"}, + result={"didntUse": True, "reason": None}, + ) + with mock.patch.object(ratings_utils.reports, "add_report"): + ratings_utils.handle_send_didnt_use_task(task) + rating = global_vars.RATINGS["abc"] + self.assertTrue(rating.didnt_use) + self.assertIsNone(rating.didnt_use_reason) + self.assertIsNone(rating.quality) + self.assertIsNone(rating.working_hours) + + def test_reasons_task_fills_the_store(self): + task = make_task( + "ratings/get_not_used_reasons", + result={"results": [{"id": 1, "label": "Didn't fit my project"}]}, + ) + ratings_utils.handle_get_not_used_reasons_task(task) + self.assertEqual( + global_vars.NOT_USED_REASONS, [{"id": 1, "label": "Didn't fit my project"}] + ) + + def test_ensure_not_used_reasons_fetches_once(self): + with mock.patch.object( + ratings_utils.client_lib, "get_not_used_reasons" + ) as fetch: + ratings_utils.ensure_not_used_reasons() + ratings_utils.ensure_not_used_reasons() + fetch.assert_called_once() + self.assertEqual(global_vars.NOT_USED_REASONS, []) + + def test_ensure_didnt_use_fetches_only_unfetched(self): + global_vars.RATINGS["abc"] = datas.AssetRating(didnt_use_fetched=True) + with mock.patch.object(ratings_utils.client_lib, "get_didnt_use") as fetch: + ratings_utils.ensure_didnt_use("abc") + ratings_utils.ensure_didnt_use("new-asset") + fetch.assert_called_once_with("new-asset") + + def test_successful_rating_clears_local_flag(self): + # Rating wins server-side (the signal drops the flag); the local + # mirror must follow or the menu would show a stale checkmark. + ratings_utils.store_didnt_use_local( + "abc", didnt_use=True, reason_label="Old", reason_id=1 + ) + task = make_task( + "ratings/send_rating", + data={"asset_id": "abc", "rating_type": "quality", "rating_value": 8}, + ) + with mock.patch.object( + ratings_utils.utils, "profile_is_validator", return_value=False + ): + ratings_utils.handle_send_rating_task(task) + self.assertFalse(global_vars.RATINGS["abc"].didnt_use) + + def test_bookmark_rating_leaves_the_flag_alone(self): + ratings_utils.store_didnt_use_local("abc", didnt_use=True) + task = make_task( + "ratings/send_rating", + data={"asset_id": "abc", "rating_type": "bookmarks", "rating_value": 1}, + ) + with mock.patch.object( + ratings_utils.utils, "profile_is_validator", return_value=False + ): + ratings_utils.handle_send_rating_task(task) + self.assertTrue(global_vars.RATINGS["abc"].didnt_use) + + +class TimerDispatchTest(unittest.TestCase): + def _assert_dispatch(self, task_type, func_name): + task = make_task(task_type) + with mock.patch.object(timer.ratings_utils, func_name) as handler: + timer.handle_task(task) + handler.assert_called_once_with(task) + + def test_didnt_use_task_types_route_to_their_handlers(self): + self._assert_dispatch( + "ratings/get_not_used_reasons", "handle_get_not_used_reasons_task" + ) + self._assert_dispatch("ratings/get_didnt_use", "handle_get_didnt_use_task") + self._assert_dispatch("ratings/send_didnt_use", "handle_send_didnt_use_task") + + +class NotUsedOperatorTest(unittest.TestCase): + """SetNotUsed translates its properties into the client calls.""" + + def setUp(self): + self._orig_ratings = dict(global_vars.RATINGS) + global_vars.RATINGS.clear() + + def tearDown(self): + global_vars.RATINGS.clear() + global_vars.RATINGS.update(self._orig_ratings) + + def _execute(self, **props): + # bpy operators can't be instantiated from Python; run execute() + # against a stub carrying the resolved property values. + operator = types.SimpleNamespace(**props) + with ( + mock.patch.object(ratings.client_lib, "send_didnt_use") as send, + mock.patch.object( + ratings.ratings_utils.client_lib, "send_rating" + ) as send_rating, + ): + result = ratings.SetNotUsed.execute(operator, context=None) + self.assertEqual(result, {"FINISHED"}) + return send, send_rating + + def test_flag_with_reason(self): + send, _ = self._execute(asset_id="abc", reason_id=4, undo=False) + send.assert_called_once_with("abc", True, 4, replace_rating=True) + + def test_a_new_attempt_clears_the_stale_inline_error(self): + ratings_utils.store_didnt_use_error( + "abc", "Only downloaded assets can be flagged." + ) + self._execute(asset_id="abc", reason_id=4, undo=False) + self.assertIsNone(global_vars.RATINGS["abc"].didnt_use_error) + + def test_flag_without_reason(self): + send, _ = self._execute(asset_id="abc", reason_id=-1, undo=False) + send.assert_called_once_with("abc", True, None, replace_rating=True) + + def test_flag_remembers_the_scores_it_replaces(self): + global_vars.RATINGS["abc"] = datas.AssetRating(quality=8, working_hours=4) + self._execute(asset_id="abc", reason_id=-1, undo=False) + rating = global_vars.RATINGS["abc"] + self.assertEqual(rating.didnt_use_replaced_quality, 8) + self.assertEqual(rating.didnt_use_replaced_working_hours, 4) + + def test_undo_without_memory_clears_the_flag(self): + send, send_rating = self._execute(asset_id="abc", reason_id=-1, undo=True) + send.assert_called_once_with("abc", False) + send_rating.assert_not_called() + + def test_undo_with_memory_restores_through_the_rating_api(self): + # Re-rating clears the flag server-side (rating wins) - no flag + # DELETE needed, and the numbers come back. + global_vars.RATINGS["abc"] = datas.AssetRating( + didnt_use=True, + didnt_use_fetched=True, + didnt_use_replaced_quality=8, + didnt_use_replaced_working_hours=4, + ) + send, send_rating = self._execute(asset_id="abc", reason_id=-1, undo=True) + send.assert_not_called() + send_rating.assert_has_calls( + [mock.call("abc", "quality", 8), mock.call("abc", "working_hours", 4)] + ) + rating = global_vars.RATINGS["abc"] + self.assertFalse(rating.didnt_use) + self.assertEqual(rating.quality, 8) + self.assertEqual(rating.working_hours, 4) + self.assertIsNone(rating.didnt_use_replaced_quality) + + def test_undo_with_partial_memory_restores_only_what_existed(self): + global_vars.RATINGS["abc"] = datas.AssetRating( + didnt_use=True, + didnt_use_fetched=True, + didnt_use_replaced_working_hours=4, + ) + send, send_rating = self._execute(asset_id="abc", reason_id=-1, undo=True) + send.assert_not_called() + send_rating.assert_called_once_with("abc", "working_hours", 4) diff --git a/timer.py b/timer.py index 6d03d1614..f873b1f09 100644 --- a/timer.py +++ b/timer.py @@ -523,6 +523,12 @@ def handle_task(task: client_tasks.Task): return ratings_utils.handle_get_ratings_task(task) if task.task_type == "ratings/send_rating": return ratings_utils.handle_send_rating_task(task) + if task.task_type == "ratings/get_not_used_reasons": + return ratings_utils.handle_get_not_used_reasons_task(task) + if task.task_type == "ratings/get_didnt_use": + return ratings_utils.handle_get_didnt_use_task(task) + if task.task_type == "ratings/send_didnt_use": + return ratings_utils.handle_send_didnt_use_task(task) # HANDLE BOOKMARKS if task.task_type == "ratings/get_bookmarks":