diff --git a/envs/conda/dev.yaml b/envs/conda/dev.yaml
index 35f273aac..697a3f6b2 100644
--- a/envs/conda/dev.yaml
+++ b/envs/conda/dev.yaml
@@ -30,7 +30,15 @@ dependencies:
- django-crispy-forms >=1.13.0, <=1.14.0
- django-pandas >=0.6.6, <=0.6.6
- django-filter >=21.1, <=22.1
- - django_unicorn >=0.59.0, <=0.59.0
+ #
+ # A fork of django-unicorn was made, which we now use + its deps
+ # - django_unicorn >=0.59.0, <=0.59.0
+ - beautifulsoup4 >=4.8.0
+ - orjson >=3.6.0
+ - shortuuid >=1.0.1
+ - cachetools >=4.1.1
+ - decorator >=4.4.2
+ #
- django-simple-history >=3.3.0, <=3.3.0
- djangorestframework >=3.13.1, <3.15.2
- ipython >=8.22.2, <=8.29.0
diff --git a/pyproject.toml b/pyproject.toml
index e8fe75e80..8f705f915 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -72,7 +72,6 @@ dependencies=[
"django-contrib-comments >=2.2.0, <=2.2.0", # for tracking user comments in web
"django-crispy-forms >=1.13.0, <=1.14.0", # for formatting of online forms
"django-pandas >=0.6.6, <=0.6.6", # for converting QuerySets to PandasDataFrames
- "django-unicorn >=0.59.0, <=0.59.0", # for responsive web UI (AJAX calls)
"dj-database-url >=0.5.0, <1.4.0", # for DigitalOcean URL conversion
"django-simple-history >=3.3.0, <=3.3.0", # for tracking changes to data
"djangorestframework >=3.13.1, <3.15.2", # for our REST API
@@ -99,6 +98,14 @@ dependencies=[
# "rdkit", # cheminformatics library # BUG: install is broken on pypi
# "ipython", # for rendering molecules in output
# "umap-learn", # for chemspace mapping
+ #
+ # A fork of django-unicorn was made, which we now use + its deps
+ # "django-unicorn >=0.59.0, <=0.59.0", # for responsive web UI (AJAX calls)
+ "beautifulsoup4 >=4.8.0",
+ "orjson >=3.6.0",
+ "shortuuid >=1.0.1",
+ "cachetools >=4.1.1",
+ "decorator >=4.4.2",
]
# optional dependencies that are not installed by default
diff --git a/src/simmate/configuration/django/settings.py b/src/simmate/configuration/django/settings.py
index b33f35791..388e377e8 100644
--- a/src/simmate/configuration/django/settings.py
+++ b/src/simmate/configuration/django/settings.py
@@ -60,7 +60,8 @@
#
# Django unicorn acts as a frontend framework for making dyanmic webpages
# (i.e. AJAX calls can be made to update the views)
- "django_unicorn",
+ # "django_unicorn",
+ "simmate.website.configs.UnicornConfig", # fork of django_unicorn
#
# Django simple history lets you track history of changes (and who made
# those changes) for a given model. This is important for models that users
diff --git a/src/simmate/website/configs.py b/src/simmate/website/configs.py
index 7d78395d9..bd3c7c4c6 100644
--- a/src/simmate/website/configs.py
+++ b/src/simmate/website/configs.py
@@ -11,6 +11,10 @@ class DataExplorerConfig(AppConfig):
name = "simmate.website.data_explorer"
+class UnicornConfig(AppConfig):
+ name = "simmate.website.unicorn"
+
+
class UserTrackingConfig(AppConfig):
name = "simmate.website.user_tracking"
diff --git a/src/simmate/website/core/urls.py b/src/simmate/website/core/urls.py
index b94abfb8a..3225a1e5b 100644
--- a/src/simmate/website/core/urls.py
+++ b/src/simmate/website/core/urls.py
@@ -108,7 +108,17 @@ def get_disabled_urls():
path(route="about/", view=views.about, name="about"),
#
# Django-unicorn urls
- path("unicorn/", include("django_unicorn.urls")),
+ path(
+ route="unicorn/",
+ view=include(
+ (
+ "simmate.website.unicorn.urls",
+ "simmate.website.unicorn",
+ ),
+ namespace="unicorn",
+ ),
+ name="unicorn",
+ ),
#
# Django-contrib-comments urls
path("comments/", include("django_comments.urls")),
diff --git a/src/simmate/website/core_components/components/base.py b/src/simmate/website/core_components/components/base.py
index f3d89c0ad..59547ebcb 100644
--- a/src/simmate/website/core_components/components/base.py
+++ b/src/simmate/website/core_components/components/base.py
@@ -4,10 +4,10 @@
import urllib
from django.shortcuts import redirect
-from django_unicorn.components import UnicornView
from simmate.database.base_data_types import DatabaseTable
from simmate.toolkit import Molecule, Structure
+from simmate.website.unicorn.components import UnicornView
from simmate.website.utilities import parse_request_get
@@ -442,7 +442,7 @@ def set_property(
# attempt casting to correct type
new_value = parse_value(new_value)
# buggy
- # from django_unicorn.typer import cast_attribute_value
+ # from simmate.website.unicorn.typer import cast_attribute_value
# new_value = cast_attribute_value(self, property_name, new_value)
# check if there is a special defined method for this property
diff --git a/src/simmate/website/data_explorer/components/api_filter.py b/src/simmate/website/data_explorer/components/api_filter.py
index fff688922..4ab47e14b 100644
--- a/src/simmate/website/data_explorer/components/api_filter.py
+++ b/src/simmate/website/data_explorer/components/api_filter.py
@@ -1,9 +1,9 @@
from django.shortcuts import redirect
-from django_unicorn.components import UnicornView
# from simmate.configuration import settings
from simmate.database.base_data_types import DatabaseTable, FilteredScope, table_column
from simmate.website.data_explorer.views import EXPLORABLE_TABLES
+from simmate.website.unicorn.components import UnicornView
from simmate.website.utilities import parse_request_get
# TODO: move to util and combine with var used in views.py
diff --git a/src/simmate/website/unicorn/__init__.py b/src/simmate/website/unicorn/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/simmate/website/unicorn/actions/__init__.py b/src/simmate/website/unicorn/actions/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/simmate/website/unicorn/actions/backend/__init__.py b/src/simmate/website/unicorn/actions/backend/__init__.py
new file mode 100644
index 000000000..bbc50c105
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/backend/__init__.py
@@ -0,0 +1,9 @@
+# order of imports is important to prevent circular deps
+from .base import BackendAction
+from .call_method import CallMethod
+from .refresh import Refresh
+from .reset import Reset
+from .set_attribute import SetAttribute
+from .sync_input import SyncInput
+from .toggle import Toggle
+from .validate import Validate
diff --git a/src/simmate/website/unicorn/actions/backend/base.py b/src/simmate/website/unicorn/actions/backend/base.py
new file mode 100644
index 000000000..3a2892107
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/backend/base.py
@@ -0,0 +1,120 @@
+from abc import ABC, abstractmethod
+
+from simmate.website.unicorn.actions.frontend import FrontendAction
+from simmate.website.unicorn.components import Component
+
+
+class BackendAction(ABC):
+ """
+ Abstract base class for Unicorn Actions that get queued & applied to components
+ in the backend (via python). This base class also has helper methods for
+ dynamically loading various Action types.
+
+ This class and its methods are typically handled by the FrontendAction class
+ """
+
+ # --- Abstract methods / attrs that must be set in the subclass ---
+
+ action_type: str = None
+ """
+ A name for this action that is used in JSON from the frontend
+ """
+
+ @abstractmethod
+ def apply(
+ self,
+ component: Component,
+ request, # : ComponentRequest,
+ ) -> tuple[Component, FrontendAction]:
+ """
+ Applies the update to the component and returns the ActionResult if
+ there is one. Must be defined in all subclasses.
+ """
+ raise NotImplementedError()
+
+ # --- Built-in methods ---
+
+ def __init__(self, payload: dict, partials: list):
+ self.payload = payload if payload is not None else {}
+ self.partials = partials if partials is not None else []
+
+ def __repr__(self):
+ return (
+ f"BackEnd Action: {self.__class__.__name__}"
+ f"(action_type='{self.action_type}' payload={self.payload} "
+ f"partials={self.partials})"
+ )
+
+ def to_dict(self) -> dict:
+ """
+ Converts the Action back to a dictionary match what the frontend gives
+ """
+ return {
+ "partials": self.partials,
+ "payload": self.payload,
+ "type": self.action_type,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict):
+ expected_type = data.get("type")
+ if expected_type != cls.action_type:
+ raise ValueError(
+ f"Action type mismatch. Type '{expected_type}' "
+ f"was provided, but class only accepts '{cls.action_type}'"
+ )
+ return cls(
+ payload=data.get("payload", {}),
+ partials=data.get("partials", []),
+ )
+
+ # --- Utility methods that help interact with *all* Action subclasses ---
+
+ @classmethod
+ def from_many_dicts(cls, data: list[dict]):
+ """
+ Given a list of config dictionaries, this will create and return
+ a list Action objects in the proper Action subclass.
+
+ This input is typically grabbed directly from `request.body.actionQueue`
+ """
+
+ mappings = cls.get_action_type_mappings()
+
+ actions = []
+ for config in data:
+ action_type = config["type"]
+
+ if action_type not in mappings.keys():
+ raise ValueError(f"Unknown Action type: '{action_type}'")
+
+ action_class = mappings[action_type]
+ action = action_class.from_dict(config)
+ actions.append(action)
+
+ return actions
+
+ def get_action_type_mappings() -> dict:
+ """
+ Gives a mapping of action_type to the Action subclass that should be
+ used. For example: {"callMethod": simmate.website.unicorn.actions.backend.CallMethod}
+ """
+ # TODO: We assume only internal Actions for now, but we may want to
+ # support customer user Actions.
+
+ # local import to prevent circular deps
+ from simmate.website.unicorn.actions.backend import CallMethod, SyncInput
+
+ return {
+ action.action_type: action
+ for action in [
+ CallMethod,
+ SyncInput,
+ # !!! These are special cases.
+ # See CallMethod.from_dict for details.
+ # Refresh,
+ # Reset,
+ # Toggle,
+ # Validate,
+ ]
+ }
diff --git a/src/simmate/website/unicorn/actions/backend/call_method.py b/src/simmate/website/unicorn/actions/backend/call_method.py
new file mode 100644
index 000000000..481b1d67f
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/backend/call_method.py
@@ -0,0 +1,297 @@
+from typing import Union
+
+from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
+from django.db.models import Model
+from django.http.response import HttpResponseRedirect
+
+from simmate.website.unicorn.actions.frontend import FrontendAction
+from simmate.website.unicorn.call_method_parser import parse_call_method_name
+from simmate.website.unicorn.components import Component
+from simmate.website.unicorn.typer import cast_value, get_type_hints
+from simmate.website.unicorn.utils import get_method_arguments
+
+from .base import BackendAction
+
+try:
+ from typing import get_origin
+except ImportError:
+
+ def get_origin(type_hint):
+ if hasattr(type_hint, "__origin__"):
+ return type_hint.__origin__
+
+
+MIN_VALIDATION_ERROR_ARGS = 2
+
+
+class CallMethod(BackendAction):
+
+ action_type = "callMethod"
+
+ @classmethod
+ def from_dict(cls, data: dict):
+
+ # Ideally, we could utilize the `Action.get_action_type_mappings` to
+ # determine all Action types. However, callMethod can lead to various
+ # subclasses like Refresh/Reset/Toggle.
+ # It'd be nice if these subclasses return a different action_type from
+ # the frontend but I'm not sure if that's easily achieved.
+ # If that's ever added, then this from_dict method can be removed.
+ # For now we need to create a CallMethod class and inspect it to
+ # decide whether to "punt" it to another Action type.
+
+ # local import to prevent circular deps
+ from simmate.website.unicorn.actions.backend import (
+ Refresh,
+ Reset,
+ SetAttribute,
+ Toggle,
+ Validate,
+ )
+
+ # This is the same as 'method_str' property above - but we don't have
+ # an obj yet.
+ method_str = data["payload"]["name"]
+
+ # Note: all cases return a different Action subclass
+ # if "=" in method_name: --> kwargs give with method
+ # return SetAttr.from_dict(data)
+ if method_str == "$reset":
+ return Reset.from_dict(data)
+ elif method_str == "$refresh":
+ return Refresh.from_dict(data)
+ elif method_str == "$validate":
+ return Validate.from_dict(data)
+ elif method_str.startswith("$toggle"):
+ return Toggle.from_dict(data)
+ elif "=" in method_str and "(" not in method_str:
+ # e.g. 'some_attribute=123'
+ # but NOT something like 'some_method(key=123)'
+ return SetAttribute.from_dict(data)
+ else:
+ # then we indeed have a CallMethod action and can use the normal
+ # from_dict method to init
+ return super().from_dict(data)
+
+ def apply(
+ self,
+ component: Component,
+ request, # : ComponentRequest,
+ ) -> tuple[Component, FrontendAction]:
+
+ # Get all information needed for us to apply the method
+ component_with_method = self._get_component_with_method(component)
+ method_name = self.method_name
+ method_args = self.method_args
+ method_kwargs = self.method_kwargs
+
+ # Now apply the method.
+ # We do this inside a try/except in case the method works but validation
+ # fails for the component afterwards.
+ try:
+ # call pre-method hook
+ component_with_method.calling(method_name, method_args)
+
+ # call the method
+ method_return_value = self._call_method_name(
+ component_with_method,
+ method_name,
+ method_args,
+ method_kwargs,
+ )
+
+ # call post-method hook
+ component_with_method.called(method_name, method_args)
+
+ # ------------- TODO: improve this section's refactor -------------
+ # This should not be handled within callMethod but instead at a
+ # higher level. Or maybe `set_metadata` only needs to be called
+ # with CallMethod actions...?
+
+ # if its not already a subclass object, then we wrap it in a MethodResult,
+ # which also subclasses FrontendAction
+ if not isinstance(method_return_value, FrontendAction):
+
+ # local import to prevent circular deps
+ from simmate.website.unicorn.actions.frontend import (
+ MethodResult,
+ Redirect,
+ )
+
+ # special case: redirect objects need are converted to Redirect
+ if isinstance(method_return_value, HttpResponseRedirect):
+ method_return_value = Redirect.from_django(method_return_value)
+ else:
+ method_return_value = MethodResult(value=method_return_value)
+
+ # Unicorn frontend needs to know where this FrontendAction came from
+ method_return_value.set_metadata(
+ method_name,
+ method_args,
+ method_kwargs,
+ )
+ # -----------------------------------------------------------------
+
+ return component, method_return_value
+
+ except ValidationError as e:
+ self._apply_validation_error(component, e)
+
+ # no FrontendAction needed
+ return component, None
+
+ @property
+ def method_str(self):
+ return self.payload["name"]
+
+ # OPTIMIZE: consider caching because it's used repeatedly
+ # Alternatively, just build this during init
+ @property
+ def method_config(self):
+
+ # The "replace" handles the special case where
+ # "$parent.some_method" is given in the method_str, which we ignore for
+ # now (it is handled in _get_component_with_method)
+ method_str = self.method_str.replace("$parent.", "")
+
+ # returns a tuple of (method_name, args, kwargs)
+ # !!! This is the only place this util is used... Consider refactor
+ # and pulling it in here.
+ return parse_call_method_name(method_str)
+
+ def _get_component_with_method(self, component):
+
+ if "$parent" in self.method_str:
+ parent_component = component.parent
+ if not parent_component:
+ raise Exception(
+ "$parent was requested but Component does not have a parent set"
+ )
+ parent_component.force_render = True
+ return parent_component
+
+ else:
+ return component
+
+ @property
+ def method_name(self):
+ return self.method_config[0]
+
+ @property
+ def method_args(self):
+ return self.method_config[1]
+
+ @property
+ def method_kwargs(self):
+ return self.method_config[2]
+
+ @staticmethod
+ def _apply_validation_error(self, component, e):
+ if len(enumerate().args) < MIN_VALIDATION_ERROR_ARGS or not e.args[1]:
+ raise AssertionError("Error code must be specified") from e
+
+ if hasattr(e, "error_list"):
+ error_code = e.args[1]
+
+ for error in e.error_list:
+ if NON_FIELD_ERRORS in component.errors:
+ component.errors[NON_FIELD_ERRORS].append(
+ {"code": error_code, "message": error.message}
+ )
+ else:
+ component.errors[NON_FIELD_ERRORS] = [
+ {"code": error_code, "message": error.message}
+ ]
+ elif hasattr(e, "message_dict"):
+ for field, message in e.message_dict.items():
+ if not e.args[1]:
+ raise AssertionError("Error code must be specified") from e
+
+ error_code = e.args[1]
+
+ if field in component.errors:
+ component.errors[field].append(
+ {"code": error_code, "message": message}
+ )
+ else:
+ component.errors[field] = [{"code": error_code, "message": message}]
+
+ # TODO: refactor and consider moving to a method of Component
+ @staticmethod
+ def _call_method_name(
+ component: Component,
+ method_name: str,
+ args: tuple[any],
+ kwargs: dict[str, any],
+ ) -> any:
+ """
+ Calls the method name with parameters.
+
+ Args:
+ param component: Component to call method on.
+ param method_name: Method name to call.
+ param args: Tuple of arguments for the method.
+ param kwargs: Dictionary of kwargs for the method.
+ """
+
+ if method_name is not None and hasattr(component, method_name):
+ func = getattr(component, method_name)
+
+ parsed_args = []
+ parsed_kwargs = {}
+ arguments = get_method_arguments(func)
+ type_hints = get_type_hints(func)
+
+ for argument in arguments:
+ if argument in type_hints:
+ type_hint = type_hints[argument]
+
+ # Check that the type hint is a regular class or Union
+ # (which will also include Optional)
+ # TODO: Use types.UnionType to handle `|` for newer unions
+ if (
+ not isinstance(type_hint, type)
+ and get_origin(type_hint) is not Union
+ ):
+ continue
+
+ is_model = False
+
+ try:
+ is_model = issubclass(type_hint, Model)
+ except TypeError:
+ pass
+
+ if is_model:
+ DbModel = type_hint
+ key = "pk"
+ value = None
+
+ if not kwargs:
+ value = args[len(parsed_args)]
+ parsed_args.append(DbModel.objects.get(**{key: value}))
+ else:
+ value = kwargs.get("pk")
+ parsed_kwargs[argument] = DbModel.objects.get(
+ **{key: value}
+ )
+
+ elif argument in kwargs:
+ parsed_kwargs[argument] = cast_value(
+ type_hint, kwargs[argument]
+ )
+ elif len(args) > len(parsed_args):
+ parsed_args.append(
+ cast_value(type_hint, args[len(parsed_args)])
+ )
+ elif argument in kwargs:
+ parsed_kwargs[argument] = kwargs[argument]
+ else:
+ parsed_args.append(args[len(parsed_args)])
+
+ if parsed_args:
+ return func(*parsed_args, **parsed_kwargs)
+ elif parsed_kwargs:
+ return func(**parsed_kwargs)
+ else:
+ return func()
diff --git a/src/simmate/website/unicorn/actions/backend/refresh.py b/src/simmate/website/unicorn/actions/backend/refresh.py
new file mode 100644
index 000000000..c6304ccfb
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/backend/refresh.py
@@ -0,0 +1,35 @@
+from simmate.website.unicorn.actions.frontend import FrontendAction
+from simmate.website.unicorn.components import Component
+from simmate.website.unicorn.views.utils import set_property_from_data
+
+from .base import BackendAction
+
+
+class Refresh(BackendAction):
+
+ action_type = "callMethod"
+ method_name = "$refresh"
+
+ def apply(
+ self,
+ component: Component,
+ request, # : ComponentRequest,
+ ) -> tuple[Component, FrontendAction]:
+
+ # grab a clean object - can be from cache
+ updated_component = Component.get_or_create(
+ # we keep the original component's id and name
+ component_id=component.component_id,
+ component_name=component.component_name,
+ request=request.request,
+ use_cache=True,
+ )
+
+ # Set component properties based on request data
+ for property_name, property_value in request.data.items():
+ set_property_from_data(updated_component, property_name, property_value)
+
+ updated_component.hydrate()
+
+ # no FrontendAction needed
+ return updated_component, None
diff --git a/src/simmate/website/unicorn/actions/backend/reset.py b/src/simmate/website/unicorn/actions/backend/reset.py
new file mode 100644
index 000000000..200a8791d
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/backend/reset.py
@@ -0,0 +1,30 @@
+from simmate.website.unicorn.actions.frontend import FrontendAction
+from simmate.website.unicorn.components import Component
+
+from .base import BackendAction
+
+
+class Reset(BackendAction):
+
+ action_type = "callMethod"
+ method_name = "$reset"
+
+ def apply(
+ self,
+ component: Component,
+ request, # : ComponentRequest,
+ ) -> tuple[Component, FrontendAction]:
+
+ # create a clean object -- ignore cache
+ updated_component = Component.create(
+ # we keep the original component's id and name
+ component_id=component.component_id,
+ component_name=component.component_name,
+ request=request.request,
+ )
+
+ # Explicitly remove all errors and prevent validation from firing before render()
+ updated_component.errors = {}
+
+ # no FrontendAction needed
+ return updated_component, None
diff --git a/src/simmate/website/unicorn/actions/backend/set_attribute.py b/src/simmate/website/unicorn/actions/backend/set_attribute.py
new file mode 100644
index 000000000..f682364d7
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/backend/set_attribute.py
@@ -0,0 +1,55 @@
+from simmate.website.unicorn.actions.frontend import FrontendAction
+from simmate.website.unicorn.components import Component
+
+from .base import BackendAction
+from .utils import set_property_value
+
+
+class SetAttribute(BackendAction):
+
+ action_type = "callMethod"
+ method_name = "set_property" # TODO: add as method to Component class
+
+ def apply(
+ self,
+ component: Component,
+ request, # : ComponentRequest,
+ ) -> tuple[Component, FrontendAction]:
+
+ set_property_value(
+ component,
+ self.attribute_to_set,
+ self.new_attribute_value,
+ )
+
+ # TODO: build FrontendAction for this
+ # return_data = Return(property_name, [property_value])
+
+ return component, None
+
+ @property
+ def attribute_to_set(self):
+ return self.attribute_config[0]
+
+ @property
+ def new_attribute_value(self):
+ return self.attribute_config[1]
+
+ # OPTIMIZE: consider caching because it's used repeatedly
+ # Alternatively, just build this during init
+ @property
+ def attribute_config(self):
+ # TODO: add support for '$parent.example=123'
+
+ # it's under "name" because this normally is callMethod -> name of method
+ input_str = self.payload["name"]
+
+ if input_str.count("=") != 1:
+ raise ValueError(
+ "Improper SetAttribute config. Expected something like "
+ f"'attribute=123' but recieved '{input_str}'"
+ )
+
+ attribute_name, new_value = self.payload["name"].split("=")
+
+ return attribute_name, new_value
diff --git a/src/simmate/website/unicorn/actions/backend/sync_input.py b/src/simmate/website/unicorn/actions/backend/sync_input.py
new file mode 100644
index 000000000..28e7e9ae1
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/backend/sync_input.py
@@ -0,0 +1,27 @@
+from simmate.website.unicorn.actions.backend.utils import set_property_value
+from simmate.website.unicorn.actions.frontend import FrontendAction
+from simmate.website.unicorn.components import Component
+
+from .base import BackendAction
+
+
+class SyncInput(BackendAction):
+
+ action_type = "syncInput"
+
+ def apply(
+ self,
+ component: Component,
+ request, # : ComponentRequest,
+ ) -> tuple[Component, FrontendAction]:
+
+ property_name = self.payload["name"]
+ property_value = self.payload["value"]
+ set_property_value(
+ component,
+ property_name,
+ property_value,
+ )
+
+ # no FrontendAction needed
+ return component, None
diff --git a/src/simmate/website/unicorn/actions/backend/toggle.py b/src/simmate/website/unicorn/actions/backend/toggle.py
new file mode 100644
index 000000000..0890f193c
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/backend/toggle.py
@@ -0,0 +1,39 @@
+from simmate.website.unicorn.actions.frontend import FrontendAction
+from simmate.website.unicorn.call_method_parser import parse_call_method_name
+from simmate.website.unicorn.components import Component
+
+from .base import BackendAction
+from .utils import set_property_value
+
+
+class Toggle(BackendAction):
+
+ action_type = "callMethod"
+ method_name = "$toggle"
+
+ def apply(
+ self,
+ component: Component,
+ request, # : ComponentRequest,
+ ) -> tuple[Component, FrontendAction]:
+ for property_name in self.properties_to_toggle:
+ property_value = component._get_property(property_name)
+
+ if not isinstance(property_value, bool):
+ raise ValueError("You can only call '$toggle' on boolean attributes")
+
+ # toggle/flip the boolean
+ property_value = not property_value
+
+ # !!! consider making this util a method of Component
+ set_property_value(component, property_name, property_value)
+
+ # no FrontendAction needed
+ return component, None
+
+ @property
+ def properties_to_toggle(self):
+ # !!! Code is pretty much copy/pasted from CallMethod's method_config
+ # so I need to continue the refactor here.
+ method_str = self.payload["name"].replace("$parent.", "")
+ return parse_call_method_name(method_str)[1] # 2nd element in tuple is "args"
diff --git a/src/simmate/website/unicorn/actions/backend/utils.py b/src/simmate/website/unicorn/actions/backend/utils.py
new file mode 100644
index 000000000..49fa9b33f
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/backend/utils.py
@@ -0,0 +1,143 @@
+from typing import Any, Dict, Optional
+
+from django.db.models import QuerySet
+
+from simmate.website.unicorn.components import UnicornView
+from simmate.website.unicorn.decorators import timed
+
+# BUG: request data should never be updated. Making it immutible
+# lets us look backwards and see the original request data and run checks along
+# the way. So I remove passing the 'data' kwarg in calls to this util
+# (such as in SyncInput). I don't see any other areas this is used, but to be
+# safe, I leave the data kwarg here for now - @jacksund
+
+# TODO: convert set_property_value util to a Component method
+
+
+@timed
+def set_property_value(
+ component: UnicornView,
+ property_name: str,
+ property_value: Any,
+ data: Optional[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 {}.
+ """
+
+ if property_name is None:
+ raise AssertionError("Property name is required")
+ if property_value is None:
+ raise AssertionError("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,
+ call_updating_method=False,
+ call_updated_method=True,
+ )
+ 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.name
+
+ if field.auto_created:
+ 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, (QuerySet, list)):
+ # TODO: Check for iterable instead of list? `from collections.abc import Iterable`
+ property_name_part_int = int(property_name_part)
+
+ if idx == len(property_name_parts) - 1:
+ component_or_field[property_name_part_int] = property_value
+ data_or_dict[property_name_part_int] = property_value
+ else:
+ component_or_field = component_or_field[property_name_part_int]
+ data_or_dict = data_or_dict[property_name_part_int]
+ else:
+ break
+
+ component.updated(property_name, property_value)
diff --git a/src/simmate/website/unicorn/actions/backend/validate.py b/src/simmate/website/unicorn/actions/backend/validate.py
new file mode 100644
index 000000000..6acd7e63f
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/backend/validate.py
@@ -0,0 +1,19 @@
+from simmate.website.unicorn.actions.frontend import FrontendAction
+from simmate.website.unicorn.components import Component
+
+from .base import BackendAction
+
+
+class Validate(BackendAction):
+
+ action_type = "callMethod"
+ method_name = "$validate"
+
+ def apply(
+ self,
+ component: Component,
+ request, # : ComponentRequest,
+ ) -> tuple[Component, FrontendAction]:
+ # !!! This duplicates work done in ComponentResponse.from_context
+ component.validate()
+ return component, None
diff --git a/src/simmate/website/unicorn/actions/frontend/__init__.py b/src/simmate/website/unicorn/actions/frontend/__init__.py
new file mode 100644
index 000000000..063bac9eb
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/frontend/__init__.py
@@ -0,0 +1,7 @@
+# order of imports is important to prevent circular deps
+from .base import FrontendAction
+from .hash_update import HashUpdate
+from .location_update import LocationUpdate
+from .method_result import MethodResult
+from .poll_update import PollUpdate
+from .redirect import Redirect
diff --git a/src/simmate/website/unicorn/actions/frontend/base.py b/src/simmate/website/unicorn/actions/frontend/base.py
new file mode 100644
index 000000000..144acd5fb
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/frontend/base.py
@@ -0,0 +1,100 @@
+import logging
+from abc import ABC, abstractmethod
+
+from simmate.website.unicorn.serializer import dumps, loads
+
+logger = logging.getLogger(__name__)
+
+
+class FrontendAction(ABC):
+ """
+ Any action or update to be performed on the frontend via client-side
+ javascript. Specifically, this defines the json response needed to call
+ actions on the fronted.
+
+ Objects of this class and its methods are typically generated by
+ FrontendActions and then used to build a ComponentResponse. This whole
+ process is managed by a ComponentRequest.
+ """
+
+ method_name: str = None
+ """
+ Name of the Component method that provided this FrontendAction
+ """
+
+ method_args: list[str] = None
+ """
+ List of args that were used in creating this FrontendAction
+ """
+
+ method_kwargs: list[str] = None
+ """
+ List of kwargs that were used in creating this FrontendAction
+ """
+
+ @property
+ def is_metadata_set(self) -> bool:
+ if None in (self.method_name, self.method_args, self.method_kwargs):
+ return False
+ else:
+ return True
+
+ def set_metadata(self, method_name, args: list = None, kwargs: dict = None):
+ """
+ The frontend often needs to know where this FrontendAction came from.
+ This is method is often used in
+ """
+ self.method_name = method_name
+ self.method_args = args if args is not None else []
+ self.method_kwargs = kwargs if kwargs is not None else {}
+
+ @abstractmethod
+ def get_payload_value(self) -> any:
+ """
+ Sets what should be returned with the 'value' key in the final dict
+
+ This can be treated as an abstractmethod and overwritten in subclasses.
+ By default the __dict__ of the class will be returned
+ """
+ return self.value
+
+ def to_dict(self) -> dict:
+ """
+ Converts this action to dictionary for the frontend to use.
+ All values in the {key: value} output must be json-serialized or as
+ basic python types (str, int, float, boolean).
+ """
+ # bug-check
+ assert self.is_metadata_set
+
+ try:
+
+ value = self.get_payload_value()
+
+ # json-serialize
+ serialized_value = loads(dumps(value))
+ serialized_args = loads(dumps(self.method_args))
+ serialized_kwargs = loads(dumps(self.method_kwargs))
+
+ return {
+ "method": self.method_name,
+ "args": serialized_args,
+ "kwargs": serialized_kwargs,
+ "value": serialized_value,
+ }
+
+ except Exception as e:
+ # !!! Why do we fail silently here? - @jacksund
+ logger.exception(e)
+
+ return {}
+
+ def get_response_data(self):
+ """
+ Builds on top of to_dict to format the ComponentResponse. This is needed
+ because sometimes the response data for this frontend action is needed
+ in multiple places (see PollUpdate as an example).
+
+ By default it only updates the "return" value
+ """
+ return {"return": self.to_dict()}
diff --git a/src/simmate/website/unicorn/actions/frontend/hash_update.py b/src/simmate/website/unicorn/actions/frontend/hash_update.py
new file mode 100644
index 000000000..3aa15cad0
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/frontend/hash_update.py
@@ -0,0 +1,24 @@
+from .base import FrontendAction
+
+
+class HashUpdate(FrontendAction):
+ """
+ Updates the current URL hash from an action method.
+ """
+
+ def __init__(self, url_hash: str):
+ """
+ Args:
+ param url_hash: The URL hash to change. Example: `#model-123`.
+ """
+ self.url_hash = url_hash
+
+ def get_payload_value(self):
+ return {"hash": self.url_hash}
+
+ def get_response_data(self):
+ # The payload value is needed in two places
+ return {
+ "redirect": self.get_payload_value(),
+ "return": self.to_dict(),
+ }
diff --git a/src/simmate/website/unicorn/actions/frontend/location_update.py b/src/simmate/website/unicorn/actions/frontend/location_update.py
new file mode 100644
index 000000000..37d52d1ce
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/frontend/location_update.py
@@ -0,0 +1,32 @@
+from django.http import HttpResponseRedirect
+
+from .base import FrontendAction
+
+
+class LocationUpdate(FrontendAction):
+ """
+ Updates the current URL from an action method.
+ """
+
+ def __init__(self, redirect: HttpResponseRedirect, title: str | None = None):
+ """
+ Args:
+ param redirect: The redirect that contains the URL to redirect to.
+ param title: The new title of the page. Optional.
+ """
+ self.url = redirect.url
+ self.title = title
+
+ def get_payload_value(self):
+ return {
+ "refresh": True,
+ "title": self.title,
+ "url": self.url,
+ }
+
+ def get_response_data(self):
+ # The payload value is needed in two places
+ return {
+ "redirect": self.get_payload_value(),
+ "return": self.to_dict(),
+ }
diff --git a/src/simmate/website/unicorn/actions/frontend/method_result.py b/src/simmate/website/unicorn/actions/frontend/method_result.py
new file mode 100644
index 000000000..3a377bb93
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/frontend/method_result.py
@@ -0,0 +1,12 @@
+from .base import FrontendAction
+
+
+class MethodResult(FrontendAction):
+
+ def __init__(self, value=None):
+
+ # TODO: Support a tuple/list return_value which could contain multiple values
+ self.value = value or {}
+
+ def get_payload_value(self):
+ return self.value
diff --git a/src/simmate/website/unicorn/actions/frontend/poll_update.py b/src/simmate/website/unicorn/actions/frontend/poll_update.py
new file mode 100644
index 000000000..f440c836b
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/frontend/poll_update.py
@@ -0,0 +1,40 @@
+from .base import FrontendAction
+
+
+class PollUpdate(FrontendAction):
+ """
+ Updates the current poll from an action method.
+ """
+
+ def __init__(
+ self,
+ *,
+ timing: int | None = None,
+ method: str | None = None,
+ disable: bool = False,
+ ):
+ """
+ Args:
+ param timing: The timing that should be used for the poll. Optional. Defaults to `None`
+ which keeps the existing timing.
+ param method: The method that should be used for the poll. Optional. Defaults to `None`
+ which keeps the existing method.
+ param disable: Whether to disable the poll or not not. Optional. Defaults to `False`.
+ """
+ self.timing = timing
+ self.method = method
+ self.disable = disable
+
+ def get_payload_value(self):
+ return {
+ "timing": self.timing,
+ "method": self.method,
+ "disable": self.disable,
+ }
+
+ def get_response_data(self):
+ # The payload value is needed in two places
+ return {
+ "poll": self.get_payload_value(),
+ "return": self.to_dict(),
+ }
diff --git a/src/simmate/website/unicorn/actions/frontend/redirect.py b/src/simmate/website/unicorn/actions/frontend/redirect.py
new file mode 100644
index 000000000..46050d635
--- /dev/null
+++ b/src/simmate/website/unicorn/actions/frontend/redirect.py
@@ -0,0 +1,23 @@
+from django.http.response import HttpResponseRedirect
+
+from .base import FrontendAction
+
+
+class Redirect(FrontendAction):
+
+ def __init__(self, *, url: str):
+ self.url = url
+
+ @classmethod
+ def from_django(cls, redirect: HttpResponseRedirect):
+ return cls(url=redirect.url)
+
+ def get_payload_value(self):
+ return {"url": self.url}
+
+ def get_response_data(self):
+ # The payload value is needed in two places
+ return {
+ "redirect": self.get_payload_value(),
+ "return": self.to_dict(),
+ }
diff --git a/src/simmate/website/unicorn/cacher.py b/src/simmate/website/unicorn/cacher.py
new file mode 100644
index 000000000..bd60b7a3d
--- /dev/null
+++ b/src/simmate/website/unicorn/cacher.py
@@ -0,0 +1,148 @@
+import logging
+import pickle
+from typing import List
+
+from django.core.cache import caches
+from django.http import HttpRequest
+
+import simmate.website.unicorn as unicorn
+from simmate.website.unicorn.errors import UnicornCacheError
+from simmate.website.unicorn.settings import get_cache_alias
+
+logger = logging.getLogger(__name__)
+
+
+class PointerUnicornView:
+ def __init__(self, component_cache_key):
+ self.component_cache_key = component_cache_key
+ self.parent = None
+ self.children = []
+
+
+class CacheableComponent:
+ """
+ Updates a component into something that is cacheable/pickleable. Also set pointers to parents/children.
+ Use in a `with` statement or explicitly call `__enter__` `__exit__` to use. It will restore the original component
+ on exit.
+ """
+
+ def __init__(self, component):
+ self._state = {}
+ self.cacheable_component = component
+
+ def __enter__(self):
+ components = []
+ components.append(self.cacheable_component)
+
+ while components:
+ component = components.pop()
+
+ if component.component_id in self._state:
+ continue
+
+ if hasattr(component, "extra_context"):
+ extra_context = component.extra_context
+ component.extra_context = None
+ else:
+ extra_context = None
+
+ request = component.request
+ component.request = None
+
+ self._state[component.component_id] = (
+ component,
+ request,
+ extra_context,
+ component.parent,
+ component.children.copy(),
+ )
+
+ if component.parent:
+ components.append(component.parent)
+ component.parent = PointerUnicornView(
+ component.parent.component_cache_key
+ )
+
+ for index, child in enumerate(component.children):
+ components.append(child)
+ component.children[index] = PointerUnicornView(
+ child.component_cache_key
+ )
+
+ for component, *_ in self._state.values():
+ try:
+ pickle.dumps(component)
+ except (
+ TypeError,
+ AttributeError,
+ NotImplementedError,
+ pickle.PicklingError,
+ ) as e:
+ raise UnicornCacheError(
+ f"Cannot cache component '{type(component)}' because it is not picklable: {type(e)}: {e}"
+ ) from e
+
+ return self
+
+ def __exit__(self, *args):
+ for component, request, extra_context, parent, children in self._state.values():
+ component.request = request
+ component.parent = parent
+ component.children = children
+
+ if extra_context:
+ component.extra_context = extra_context
+
+ def components(self):
+ return [component for component, *_ in self._state.values()]
+
+
+def cache_full_tree(component):
+ root = component
+
+ while root.parent:
+ root = root.parent
+
+ cache = caches[get_cache_alias()]
+
+ with CacheableComponent(root) as caching:
+ for component in caching.components():
+ cache.set(component.component_cache_key, component)
+
+
+def restore_from_cache(component_cache_key: str, request: HttpRequest = None):
+ """
+ Gets a cached unicorn view by key, restoring and getting cached parents and children
+ and setting the request.
+ """
+
+ cache = caches[get_cache_alias()]
+ cached_component = cache.get(component_cache_key)
+
+ if cached_component:
+ roots = {}
+ root = cached_component
+ roots[root.component_cache_key] = root
+
+ while root.parent:
+ root = cache.get(root.parent.component_cache_key)
+ roots[root.component_cache_key] = root
+
+ to_traverse = []
+ to_traverse.append(root)
+
+ while to_traverse:
+ current = to_traverse.pop()
+ current.setup(request)
+ current._validate_called = False
+ current.calls = []
+
+ for index, child in enumerate(current.children):
+ key = child.component_cache_key
+ cached_child = roots.pop(key, None) or cache.get(key)
+
+ cached_child.parent = current
+ current.children[index] = cached_child
+ to_traverse.append(cached_child)
+
+ return cached_component
diff --git a/src/simmate/website/unicorn/call_method_parser.py b/src/simmate/website/unicorn/call_method_parser.py
new file mode 100644
index 000000000..77e46df78
--- /dev/null
+++ b/src/simmate/website/unicorn/call_method_parser.py
@@ -0,0 +1,168 @@
+import ast
+import logging
+from functools import lru_cache
+from types import MappingProxyType
+from typing import Any, Dict, List, Mapping, Tuple
+
+from simmate.website.unicorn.typer import CASTERS
+
+logger = logging.getLogger(__name__)
+
+
+class InvalidKwargError(Exception):
+ pass
+
+
+def _get_expr_string(expr: ast.expr) -> str:
+ """
+ Builds a string based on traversing `ast.Attribute` and `ast.Name` expressions.
+
+ Args:
+ expr: Expression node of the the AST tree. Only handles `ast.Attribute` and `ast.Name` expressions.
+
+ Returns:
+ String based on the expression nodes.
+ """
+
+ current_expr = expr
+ expr_str = ""
+
+ while current_expr:
+ if isinstance(current_expr, ast.Name):
+ if not expr_str:
+ expr_str = current_expr.id
+ else:
+ expr_str = f"{current_expr.id}.{expr_str}"
+
+ break
+ elif isinstance(current_expr, ast.Attribute):
+ if not expr_str:
+ expr_str = current_expr.attr
+ else:
+ expr_str = f"{current_expr.attr}.{expr_str}"
+
+ current_expr = current_expr.value
+ else:
+ break
+
+ return expr_str
+
+
+def _cast_value(value):
+ """
+ Try to cast a value based on a list of casters.
+ """
+
+ for caster in CASTERS.values():
+ try:
+ casted_value = caster(value)
+
+ if casted_value:
+ value = casted_value
+ break
+ except ValueError:
+ pass
+
+ return value
+
+
+@lru_cache(maxsize=128, typed=True)
+def eval_value(value):
+ """
+ Uses `ast.literal_eval` to parse strings into an appropriate Python primitive.
+
+ Also returns an appropriate object for strings that look like they represent datetime,
+ date, time, duration, or UUID.
+ """
+
+ try:
+ value = ast.literal_eval(value)
+ except SyntaxError:
+ value = _cast_value(value)
+
+ return value
+
+
+@lru_cache(maxsize=128, typed=True)
+def parse_kwarg(kwarg: str, *, raise_if_unparseable=False) -> Dict[str, Any]:
+ """
+ Parses a potential kwarg as a string into a dictionary.
+
+ Example:
+ `parse_kwarg("test='1'")` == `{"test": "1"}`
+
+ Args:
+ kwarg: Potential kwarg as a string. e.g. "test='1'".
+ raise_if_unparseable: Raise an error if the `kwarg` cannot be parsed. Defaults to `False`.
+
+ Returns:
+ Dictionary of key-value pairs.
+ """
+
+ try:
+ tree = ast.parse(kwarg, "eval")
+
+ if tree.body and isinstance(tree.body[0], ast.Assign):
+ assign = tree.body[0]
+
+ try:
+ target = assign.targets[0]
+ key = _get_expr_string(target)
+
+ return {key: eval_value(assign.value)}
+ except ValueError:
+ if raise_if_unparseable:
+ raise
+
+ # The value can be a template variable that will get set from the
+ # context when the templatetag is rendered, so just return the expr
+ # as a string.
+ value = _get_expr_string(assign.value)
+ return {target.id: value}
+ else:
+ raise InvalidKwargError(f"'{kwarg}' is invalid")
+ except SyntaxError as e:
+ raise InvalidKwargError(f"'{kwarg}' could not be parsed") from e
+
+
+@lru_cache(maxsize=128, typed=True)
+def parse_call_method_name(
+ call_method_name: str,
+) -> Tuple[str, Tuple[Any], Mapping[str, Any]]:
+ """
+ Parses the method name from the request payload into a set of parameters to pass to
+ a method.
+
+ Args:
+ param call_method_name: String representation of a method name with parameters,
+ e.g. "set_name('Bob')"
+
+ Returns:
+ Tuple of method_name, a list of arguments and a dict of keyword arguments
+ """
+
+ is_special_method = False
+ args: List[Any] = []
+ kwargs: Dict[str, Any] = {}
+ method_name = call_method_name
+
+ # Deal with special methods that start with a "$"
+ if method_name.startswith("$"):
+ is_special_method = True
+ method_name = method_name[1:]
+
+ tree = ast.parse(method_name, "eval")
+ statement = tree.body[0].value
+
+ if tree.body and isinstance(statement, ast.Call):
+ call = tree.body[0].value
+ method_name = call.func.id
+ args = [eval_value(arg) for arg in call.args]
+ kwargs = {kw.arg: eval_value(kw.value) for kw in call.keywords}
+
+ # Add "$" back to special functions
+ if is_special_method:
+ method_name = f"${method_name}"
+
+ # conversion to immutable types - tuple and MappingProxyType
+ return method_name, tuple(args), MappingProxyType(kwargs)
diff --git a/src/simmate/website/unicorn/components/__init__.py b/src/simmate/website/unicorn/components/__init__.py
new file mode 100644
index 000000000..392ee04c4
--- /dev/null
+++ b/src/simmate/website/unicorn/components/__init__.py
@@ -0,0 +1,24 @@
+# for depreciated imports. These classes are now in the 'actions.frontend' module
+from simmate.website.unicorn.actions.frontend import (
+ HashUpdate,
+ LocationUpdate,
+ PollUpdate,
+)
+from simmate.website.unicorn.components.mixins import ModelValueMixin
+from simmate.website.unicorn.components.unicorn_view import (
+ Component,
+ UnicornField,
+ UnicornView,
+)
+from simmate.website.unicorn.typing import QuerySetType
+
+__all__ = [
+ "Component",
+ "QuerySetType",
+ "UnicornField",
+ "UnicornView",
+ "HashUpdate",
+ "LocationUpdate",
+ "PollUpdate",
+ "ModelValueMixin",
+]
diff --git a/src/simmate/website/unicorn/components/fields.py b/src/simmate/website/unicorn/components/fields.py
new file mode 100644
index 000000000..579885d8c
--- /dev/null
+++ b/src/simmate/website/unicorn/components/fields.py
@@ -0,0 +1,7 @@
+class UnicornField:
+ """
+ Base class to provide a way to serialize a component field quickly.
+ """
+
+ def to_json(self):
+ return self.__dict__
diff --git a/src/simmate/website/unicorn/components/mixins.py b/src/simmate/website/unicorn/components/mixins.py
new file mode 100644
index 000000000..d4ddf597c
--- /dev/null
+++ b/src/simmate/website/unicorn/components/mixins.py
@@ -0,0 +1,11 @@
+from simmate.website.unicorn.serializer import model_value
+
+
+class ModelValueMixin:
+ """
+ Adds a `value` method to a model similar to `QuerySet.values(*fields)` which serializes
+ a model into a dictionary with the fields as specified in the `fields` argument.
+ """
+
+ def value(self, *fields):
+ return model_value(self, *fields)
diff --git a/src/simmate/website/unicorn/components/unicorn_template_response.py b/src/simmate/website/unicorn/components/unicorn_template_response.py
new file mode 100644
index 000000000..c6caabd8d
--- /dev/null
+++ b/src/simmate/website/unicorn/components/unicorn_template_response.py
@@ -0,0 +1,282 @@
+import logging
+import re
+from collections import deque
+
+import orjson
+from bs4 import BeautifulSoup
+from bs4.dammit import EntitySubstitution
+from bs4.element import Tag
+from bs4.formatter import HTMLFormatter
+from django.template.response import TemplateResponse
+
+from simmate.website.unicorn.decorators import timed
+from simmate.website.unicorn.errors import (
+ MissingComponentElementError,
+ MissingComponentViewElementError,
+ MultipleRootComponentElementError,
+ NoRootComponentElementError,
+)
+from simmate.website.unicorn.settings import (
+ get_minify_html_enabled,
+ get_script_location,
+)
+from simmate.website.unicorn.utils import generate_checksum, sanitize_html
+
+logger = logging.getLogger(__name__)
+
+
+# https://developer.mozilla.org/en-US/docs/Glossary/Empty_element
+EMPTY_ELEMENTS = (
+ "",
+ "",
+ "
",
+ "
",
+ "