def test_message_call_method_cache_backend_dummy(client, monkeypatch, settings): monkeypatch.setattr(unicorn_view, "COMPONENTS_MODULE_CACHE_ENABLED", True) settings.CACHES["default"][ "BACKEND"] = "django.core.cache.backends.dummy.DummyCache" component_id = shortuuid.uuid()[:8] response = post_and_get_response( client, url="/message/tests.views.fake_components.FakeComponent", data={"method_count": 0}, action_queue=[{ "payload": { "name": "test_method" }, "type": "callMethod", }], component_id=component_id, ) method_count = response["data"].get("method_count") assert method_count == 1 # Get the component again and it should be found in local memory cache view = UnicornView.create( component_name="tests.views.fake_components.FakeComponent", component_id=component_id, use_cache=True, ) # Component is retrieved from the module cache assert view.method_count == method_count
def test_init_no_component_name(): with pytest.raises(AssertionError) as e: UnicornView() assert e.exconly() == "AssertionError: Component name is required"
def set_property_value( component: UnicornView, property_name: str, property_value: Any, data: dict = None ) -> None: """ Sets properties on the component. Also updates the data dictionary which gets set back as part of the payload. Args: param component: Component to set attributes on. param property_name: Name of the property. param property_value: Value to set on the property. param data: Dictionary that gets sent back with the response. Defaults to {}. """ assert property_name is not None, "Property name is required" assert property_value is not None, "Property value is required" if not data: data = {} component.updating(property_name, property_value) """ Handles nested properties. For example, for the following component: class Author(UnicornField): name = "Neil" class TestView(UnicornView): author = Author() `payload` would be `{'name': 'author.name', 'value': 'Neil Gaiman'}` The following code updates UnicornView.author.name based the payload's `author.name`. """ property_name_parts = property_name.split(".") component_or_field = component data_or_dict = data # Could be an internal portion of data that gets set for (idx, property_name_part) in enumerate(property_name_parts): if hasattr(component_or_field, property_name_part): if idx == len(property_name_parts) - 1: if hasattr(component_or_field, "_set_property"): # Can assume that `component_or_field` is a component component_or_field._set_property(property_name_part, property_value) else: # Handle calling the updating/updated method for nested properties property_name_snake_case = property_name.replace(".", "_") updating_function_name = f"updating_{property_name_snake_case}" updated_function_name = f"updated_{property_name_snake_case}" if hasattr(component, updating_function_name): getattr(component, updating_function_name)(property_value) is_relation_field = False # Set the id property for ForeignKeys # TODO: Move some of this to utility function if hasattr(component_or_field, "_meta"): for field in component_or_field._meta.get_fields(): if field.is_relation and field.many_to_many: related_name = field.related_name or f"{field.name}_set" if related_name == property_name_part: related_descriptor = getattr( component_or_field, related_name ) related_descriptor.set(property_value) is_relation_field = True break elif field.name == property_name_part: if field.is_relation: setattr( component_or_field, field.attname, property_value, ) is_relation_field = True break if not is_relation_field: setattr(component_or_field, property_name_part, property_value) if hasattr(component, updated_function_name): getattr(component, updated_function_name)(property_value) data_or_dict[property_name_part] = property_value else: component_or_field = getattr(component_or_field, property_name_part) data_or_dict = data_or_dict.get(property_name_part, {}) elif isinstance(component_or_field, dict): if idx == len(property_name_parts) - 1: component_or_field[property_name_part] = property_value data_or_dict[property_name_part] = property_value else: component_or_field = component_or_field[property_name_part] data_or_dict = data_or_dict.get(property_name_part, {}) elif isinstance(component_or_field, list) or isinstance( component_or_field, QuerySet ): # TODO: Check for iterable instad of list? `from collections.abc import Iterable` property_name_part = int(property_name_part) if idx == len(property_name_parts) - 1: component_or_field[property_name_part] = property_value data_or_dict[property_name_part] = property_value else: component_or_field = component_or_field[property_name_part] data_or_dict = data_or_dict[property_name_part] else: break component.updated(property_name, property_value)
def test_init_snake_component_name_valid_template_name(): component = UnicornView(component_id="asdf1234", component_name="hello_world") assert component.template_name == "unicorn/hello_world.html"
def test_init_caches(): component = UnicornView(component_id="asdf1234", component_name="hello_world") assert len(component._methods_cache) == 0 assert len(component._attribute_names_cache) == 0
def test_init_component_id(): component = UnicornView(component_name="test", component_id="12345678") assert component.component_id == "12345678" assert len(component.component_id) == 8
def test_init_component_name_valid_template_name(): component = UnicornView(component_id="asdf1234", component_name="test") assert component.template_name == "unicorn/test.html"
def test_init_none_component_id(): with pytest.raises(AssertionError) as e: UnicornView(component_name="test", component_id=None) assert e.exconly() == "AssertionError: Component id is required"
def handle(component_request: ComponentRequest, component: UnicornView, payload: Dict): call_method_name = payload.get("name", "") assert call_method_name, "Missing 'name' key for callMethod" (method_name, args, kwargs) = parse_call_method_name(call_method_name) return_data = Return(method_name, args, kwargs) setter_method = {} is_refresh_called = False is_reset_called = False validate_all_fields = False if "=" in call_method_name: try: setter_method = parse_kwarg(call_method_name, raise_if_unparseable=True) except InvalidKwarg: pass if setter_method: property_name = list(setter_method.keys())[0] property_value = setter_method[property_name] set_property_value(component, property_name, property_value) return_data = Return(property_name, [property_value]) else: if method_name == "$refresh": # Handle the refresh special action component = UnicornView.create( component_id=component_request.id, component_name=component_request.name, request=component_request.request, ) # Set component properties based on request data for ( property_name, property_value, ) in component_request.data.items(): set_property_from_data(component, property_name, property_value) component.hydrate() is_refresh_called = True elif method_name == "$reset": # Handle the reset special action component = UnicornView.create( component_id=component_request.id, component_name=component_request.name, request=component_request.request, use_cache=False, ) # Explicitly remove all errors and prevent validation from firing before render() component.errors = {} is_reset_called = True elif method_name == "$toggle": for property_name in args: property_value = _get_property_value(component, property_name) property_value = not property_value set_property_value(component, property_name, property_value) elif method_name == "$validate": # Handle the validate special action validate_all_fields = True else: component.calling(method_name, args) return_data.value = _call_method_name(component, method_name, args, kwargs) component.called(method_name, args) return ( component, is_refresh_called, is_reset_called, validate_all_fields, return_data, )
def _process_component_request( request: HttpRequest, component_request: ComponentRequest ) -> Dict: """ Process a `ComponentRequest`: 1. construct a Component view 2. set all of the properties on the view from the data 3. execute the type - update the properties based on the payload for "syncInput" - call the method specified for "callMethod" 4. validate any fields specified in a Django form 5. construct a `dict` that will get returned in a `JsonResponse` later on Args: param request: HttpRequest for the function-based view. param: component_request: Component request to process. Returns: `dict` with the following structure: { "id": component_id, "dom": html, // re-rendered version of the component after actions in the payload are completed "data": {}, // updated data after actions in the payload are completed "errors": {}, // form validation errors "return": {}, // optional return value from an executed action "parent": {}, // optional representation of the parent component } """ component = UnicornView.create( component_id=component_request.id, component_name=component_request.name, request=request, ) # Get a deepcopy of the data passed in to determine what fields are updated later original_data = copy.deepcopy(component_request.data) # Set component properties based on request data for (property_name, property_value) in component_request.data.items(): set_property_from_data(component, property_name, property_value) component.hydrate() validate_all_fields = False is_reset_called = False is_refresh_called = False return_data = None partials = [] for action in component_request.action_queue: if action.partial: partials.append(action.partial) else: partials = action.partials if action.action_type == "syncInput": sync_input.handle(component_request, component, action.payload) elif action.action_type == "callMethod": ( component, _is_refresh_called, _is_reset_called, _validate_all_fields, return_data, ) = call_method.handle(component_request, component, action.payload) is_refresh_called = is_refresh_called | _is_refresh_called is_reset_called = is_reset_called | _is_reset_called validate_all_fields = validate_all_fields | _validate_all_fields else: raise UnicornViewError(f"Unknown action_type '{action.action_type}'") component.complete() # Re-load frontend context variables to deal with non-serializable properties component_request.data = orjson.loads(component.get_frontend_context_variables()) # Get set of attributes that should be marked as `safe` safe_fields = [] if hasattr(component, "Meta") and hasattr(component.Meta, "safe"): if isinstance(component.Meta.safe, Sequence): for field_name in component.Meta.safe: if field_name in component._attributes().keys(): safe_fields.append(field_name) # Mark safe attributes as such before rendering for field_name in safe_fields: value = getattr(component, field_name) if isinstance(value, str): setattr(component, field_name, mark_safe(value)) # Send back all available data for reset or refresh actions updated_data = component_request.data if not is_reset_called: if not is_refresh_called: updated_data = {} for key, value in original_data.items(): if value != component_request.data.get(key): updated_data[key] = component_request.data.get(key) if validate_all_fields: component.validate() else: component.validate(model_names=list(updated_data.keys())) # Pass the current request so that it can be used inside the component template rendered_component = component.render(request=request) component.rendered(rendered_component) cache = caches[get_cache_alias()] try: cache.set(component.component_cache_key, get_cacheable_component(component)) except UnicornCacheError as e: logger.warning(e) partial_doms = [] if partials and all(partials): soup = BeautifulSoup(rendered_component, features="html.parser") for partial in partials: partial_found = False only_id = False only_key = False target = partial.get("target") if not target: target = partial.get("key") if target: only_key = True if not target: target = partial.get("id") if target: only_id = True assert target, "Partial target is required" if not only_id: for element in soup.find_all(): if ( "unicorn:key" in element.attrs and element.attrs["unicorn:key"] == target ): partial_doms.append({"key": target, "dom": str(element)}) partial_found = True break if not partial_found and not only_key: for element in soup.find_all(): if "id" in element.attrs and element.attrs["id"] == target: partial_doms.append({"id": target, "dom": str(element)}) partial_found = True break res = { "id": component_request.id, "data": updated_data, "errors": component.errors, "calls": component.calls, "checksum": generate_checksum(orjson.dumps(component_request.data)), } if partial_doms: res.update({"partials": partial_doms}) else: hash = generate_checksum(rendered_component) if ( component_request.hash == hash and (not return_data or not return_data.value) and not component.calls ): raise RenderNotModified() # Make sure that partials with comments or blank lines before the root element only return the root element soup = BeautifulSoup(rendered_component, features="html.parser") rendered_component = str(get_root_element(soup)) res.update( { "dom": rendered_component, "hash": hash, } ) if return_data: res.update( { "return": return_data.get_data(), } ) if return_data.redirect: res.update( { "redirect": return_data.redirect, } ) if return_data.poll: res.update( { "poll": return_data.poll, } ) parent_component = component.parent if parent_component: # TODO: Should parent_component.hydrate() be called? parent_frontend_context_variables = loads( parent_component.get_frontend_context_variables() ) parent_checksum = generate_checksum(dumps(parent_frontend_context_variables)) parent = { "id": parent_component.component_id, "checksum": parent_checksum, } if not partial_doms: parent_dom = parent_component.render() component.parent_rendered(parent_dom) try: cache.set( parent_component.component_cache_key, get_cacheable_component(parent_component), ) except UnicornCacheError as e: logger.warning(e) parent.update( { "dom": parent_dom, "data": parent_frontend_context_variables, "errors": parent_component.errors, } ) res.update({"parent": parent}) return res
def test_init_kebab_component_name_valid_template_name(): component = UnicornView(component_name="hello-world") assert component.template_name == "unicorn/hello-world.html"
def test_init_none_component_id(): component = UnicornView(component_name="test", component_id=None) assert component.component_id != None assert len(component.component_id) == 8