Skip to content

Commit 8b41b07

Browse files
Merge pull request wildfish#10 from wildfish/feature/render-serializers
Feature/render serializers
2 parents 43aafd2 + dd7c7e9 commit 8b41b07

File tree

32 files changed

+466
-351
lines changed

32 files changed

+466
-351
lines changed

dashboards/component/base.py

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
from django.template import Context
88
from django.template.loader import render_to_string
99
from django.urls import reverse, reverse_lazy
10+
from django.utils.functional import lazy
1011
from django.utils.module_loading import import_string
1112
from django.utils.safestring import mark_safe
1213
from django.utils.text import slugify
1314

15+
import asset_definitions
16+
1417
from dashboards import config
1518

1619
from ..types import ValueData
@@ -127,6 +130,7 @@ def get_value(
127130
value = self.defer(request=request, object=self.object, filters=filters)
128131
else:
129132
value = self.defer
133+
130134
else:
131135
serializable = getattr(self.value, "serialize", None)
132136
if serializable:
@@ -142,28 +146,96 @@ def get_value(
142146

143147
return value
144148

145-
def render(
146-
self, context: Context, htmx: Optional[bool] = None, call_deferred: bool = False
147-
) -> str:
148-
request = context.get("request")
149+
@property
150+
def media(self):
151+
return self.get_media()
152+
153+
def _get_media_from_definition(self) -> Optional[asset_definitions.Media]:
154+
definition = getattr(self.__class__, "Media", None)
155+
if definition:
156+
return asset_definitions.Media(media=definition)
157+
158+
return None
159+
160+
def get_media(self) -> asset_definitions.Media:
161+
# component level media
162+
media = self._get_media_from_definition() or asset_definitions.Media()
163+
# serializers may have media, if so use that instead of component media
164+
if callable(self.value) and hasattr(self.value, "get_media"):
165+
media = self.value().get_media()
166+
elif callable(self.defer) and hasattr(self.defer, "get_media"):
167+
media = self.defer().get_media()
168+
169+
return media
170+
171+
def get_filters(self, request: HttpRequest) -> Dict[str, Any]:
149172
if request:
150173
filters = (
151174
request.GET.dict() if request.method == "GET" else request.POST.dict()
152175
)
153176
else:
154177
filters = {}
155178

156-
context = {
179+
return filters
180+
181+
def render_value(self, context: Context, call_deferred: bool = False) -> str:
182+
# if value is deferred and we are not ready to call it, return loading template
183+
if self.is_deferred and not call_deferred:
184+
return render_to_string(self.defer_loading_template_name)
185+
186+
request = context.get("request")
187+
filters = self.get_filters(request)
188+
189+
if self.is_deferred and self.defer and call_deferred:
190+
render = getattr(self.defer, "render", None)
191+
else:
192+
render = getattr(self.value, "render", None)
193+
194+
if callable(render):
195+
lazy_render = lazy(render)
196+
rendered_value = lazy_render(
197+
template_id=self.template_id,
198+
request=request,
199+
filters=filters,
200+
object=self.object,
201+
css_classes=self.css_classes,
202+
is_deferred=self.is_deferred,
203+
defer_url=self.get_absolute_url(),
204+
)
205+
return rendered_value
206+
207+
value = self.get_value(
208+
request=request, call_deferred=call_deferred, filters=filters
209+
)
210+
211+
template_context = {
157212
"request": request,
158213
"component": self,
159-
"rendered_value": self.get_value(
160-
request=request, call_deferred=call_deferred, filters=filters
161-
),
214+
"rendered_value": value,
215+
}
216+
return render_to_string(self.template_name, template_context)
217+
218+
def render(
219+
self, context: Context, htmx: Optional[bool] = None, call_deferred: bool = False
220+
) -> str:
221+
template_context = {
222+
"template_id": self.template_id,
223+
"object": self.object,
224+
"media": self.media,
225+
"cta": self.cta,
226+
"is_deferred": self.is_deferred,
162227
"htmx": self.is_deferred if htmx is None else htmx,
228+
"defer_url": self.get_absolute_url(),
229+
"trigger_on": self.htmx_trigger_on(),
230+
"poll_rate": self.htmx_poll_rate(),
231+
"defer_loading_template_name": self.defer_loading_template_name,
232+
"rendered_value": self.render_value(
233+
context=context, call_deferred=call_deferred
234+
),
163235
}
164236

165237
return mark_safe(
166-
render_to_string("dashboards/components/component.html", context)
238+
render_to_string("dashboards/components/component.html", template_context)
167239
)
168240

169241
def get_absolute_url(self):
@@ -192,6 +264,7 @@ def get_absolute_url(self):
192264

193265
return url
194266

267+
@property
195268
def template_id(self):
196269
return slugify(self.get_absolute_url())
197270

Lines changed: 128 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,73 @@
11
import json
2-
from typing import Any, Dict, List, Optional
2+
from typing import Any, Dict, List, Optional, Type
33

44
from django.core.exceptions import ImproperlyConfigured
5+
from django.db.models import Model
6+
from django.template.loader import render_to_string
57

8+
import asset_definitions
69
import pandas as pd
710
import plotly.graph_objs as go
811

912
from dashboards.meta import ClassWithMeta
1013

1114

12-
class ChartSerializer(ClassWithMeta):
13-
meta_layout_attrs = ["title", "width", "height"]
14-
layout: Optional[Dict[str, Any]] = None
15+
class ModelDataMixin:
16+
"""
17+
gets data from a django model and converts to a pandas dataframe
18+
"""
1519

1620
class Meta:
1721
fields: Optional[List[str]] = None
18-
model: Optional[str] = None
19-
queryset: Optional[str] = None
20-
title: Optional[str] = None
21-
width: Optional[int] = None
22-
height: Optional[int] = None
22+
model: Optional[Model] = None
2323

24-
@classmethod
25-
def preprocess_meta(cls, current_class_meta):
26-
title = getattr(current_class_meta, "title", None)
24+
_meta: Type["ModelDataMixin.Meta"]
2725

28-
if title and not hasattr(current_class_meta, "name"):
29-
current_class_meta.name = title
26+
def get_fields(self) -> Optional[List[str]]:
27+
# TODO: for some reason mypy complains about this one line
28+
return self._meta.fields # type: ignore
3029

31-
if title and not hasattr(current_class_meta, "verbose_name"):
32-
current_class_meta.verbose_name = title
30+
def convert_to_df(self, data: Any, columns: Optional[List] = None) -> pd.DataFrame:
31+
return pd.DataFrame(data, columns=columns)
3332

34-
return current_class_meta
33+
def get_data(self, *args, **kwargs) -> pd.DataFrame:
34+
fields = self.get_fields()
35+
queryset = self.get_queryset(*args, **kwargs)
36+
if fields:
37+
queryset = queryset.values(*fields)
3538

36-
@classmethod
37-
def postprocess_meta(cls, current_class_meta, resolved_meta_class):
38-
if not hasattr(resolved_meta_class, "title"):
39-
resolved_meta_class.title = resolved_meta_class.verbose_name
39+
try:
40+
df = self.convert_to_df(queryset.iterator(), fields)
41+
except KeyError:
42+
return pd.DataFrame()
43+
return df
4044

41-
return resolved_meta_class
45+
def get_queryset(self, *args, **kwargs):
46+
if self._meta.model is not None:
47+
queryset = self._meta.model._default_manager.all()
48+
else:
49+
raise ImproperlyConfigured(
50+
"%(self)s is missing a QuerySet. Define "
51+
"%(self)s.model or override "
52+
"%(self)s.get_queryset()." % {"self": self.__class__.__name__}
53+
)
54+
55+
return queryset
4256

43-
def empty_chart(self):
57+
58+
class PlotlyChartSerializerMixin:
59+
template_name: str = "dashboards/components/chart/plotly.html"
60+
meta_layout_attrs = ["title", "width", "height"]
61+
layout: Optional[Dict[str, Any]] = None
62+
63+
_meta: Type[Any]
64+
65+
class Meta:
66+
displayModeBar: Optional[bool] = True
67+
staticPlot: Optional[bool] = False
68+
responsive: Optional[bool] = True
69+
70+
def empty_chart(self) -> str:
4471
return json.dumps(
4572
{
4673
"layout": {
@@ -59,6 +86,27 @@ def empty_chart(self):
5986
}
6087
)
6188

89+
def apply_layout(self, fig: go.Figure, dark=False) -> go.Figure:
90+
layout = self.layout or {}
91+
92+
for attr in self.meta_layout_attrs:
93+
layout.setdefault(attr, getattr(self._meta, attr))
94+
95+
if dark:
96+
fig = fig.update_layout(
97+
template="plotly_dark",
98+
plot_bgcolor="rgba(0,0,0,0.05)",
99+
paper_bgcolor="rgba(0,0,0,0.05)",
100+
)
101+
102+
return fig.update_layout(**layout)
103+
104+
def get_data(self, *args, **kwargs) -> pd.DataFrame:
105+
raise NotImplementedError
106+
107+
def to_fig(self, data: Any) -> go.Figure:
108+
raise NotImplementedError
109+
62110
@classmethod
63111
def serialize(cls, **kwargs) -> str:
64112
self = cls()
@@ -69,58 +117,77 @@ def serialize(cls, **kwargs) -> str:
69117
return self.empty_chart()
70118

71119
fig = self.to_fig(df)
72-
73120
fig = self.apply_layout(
74121
fig, dark=request and request.COOKIES.get("appearanceMode") == "dark"
75122
)
76123

77124
return fig.to_json()
78125

79-
def get_fields(self) -> Optional[List[str]]:
80-
# TODO: for some reason mypy complains about this one line
81-
return self._meta.fields # type: ignore
126+
@classmethod
127+
def render(cls, template_id, **kwargs) -> str:
128+
self = cls()
129+
value = cls.serialize(**kwargs)
130+
context = {
131+
"template_id": template_id,
132+
"value": value,
133+
"displayModeBar": self._meta.displayModeBar,
134+
"staticPlot": self._meta.staticPlot,
135+
"responsive": self._meta.responsive,
136+
}
137+
return render_to_string(cls.template_name, context)
82138

83-
def apply_layout(self, fig: go.Figure, dark=False):
84-
layout = self.layout or {}
85139

86-
for attr in self.meta_layout_attrs:
87-
layout.setdefault(attr, getattr(self._meta, attr))
140+
class BaseChartSerializer(ClassWithMeta, asset_definitions.MediaDefiningClass):
141+
_meta: Type[Any]
88142

89-
if dark:
90-
fig = fig.update_layout(
91-
template="plotly_dark",
92-
plot_bgcolor="rgba(0,0,0,0.05)",
93-
paper_bgcolor="rgba(0,0,0,0.05)",
94-
)
143+
class Meta:
144+
title: Optional[str] = None
145+
width: Optional[int] = None
146+
height: Optional[int] = None
95147

96-
return fig.update_layout(**layout)
148+
@classmethod
149+
def preprocess_meta(cls, current_class_meta):
150+
title = getattr(current_class_meta, "title", None)
97151

98-
def convert_to_df(self, data: Any, columns: Optional[List] = None) -> pd.DataFrame:
99-
return pd.DataFrame(data, columns=columns)
152+
if title and not hasattr(current_class_meta, "name"):
153+
current_class_meta.name = title
100154

101-
def get_data(self, *args, **kwargs) -> pd.DataFrame:
102-
fields = self.get_fields()
103-
queryset = self.get_queryset(*args, **kwargs)
104-
if fields:
105-
queryset = queryset.values(*fields)
155+
if title and not hasattr(current_class_meta, "verbose_name"):
156+
current_class_meta.verbose_name = title
106157

107-
try:
108-
df = self.convert_to_df(queryset.iterator(), fields)
109-
except KeyError:
110-
return pd.DataFrame()
111-
return df
158+
return current_class_meta
112159

113-
def get_queryset(self, *args, **kwargs):
114-
if self._meta.model is not None:
115-
queryset = self._meta.model._default_manager.all()
116-
else:
117-
raise ImproperlyConfigured(
118-
"%(self)s is missing a QuerySet. Define "
119-
"%(self)s.model or override "
120-
"%(self)s.get_queryset()." % {"self": self.__class__.__name__}
121-
)
160+
@classmethod
161+
def postprocess_meta(cls, current_class_meta, resolved_meta_class):
162+
if not hasattr(resolved_meta_class, "title"):
163+
resolved_meta_class.title = resolved_meta_class.verbose_name
122164

123-
return queryset
165+
return resolved_meta_class
124166

125-
def to_fig(self, data: Any) -> go.Figure:
167+
@classmethod
168+
def serialize(cls, **kwargs) -> str:
126169
raise NotImplementedError
170+
171+
172+
class PlotlyChartSerializer(PlotlyChartSerializerMixin, BaseChartSerializer):
173+
"""
174+
Serializer to convert data into a plotly js format
175+
"""
176+
177+
class Meta(PlotlyChartSerializerMixin.Meta, BaseChartSerializer.Meta):
178+
pass
179+
180+
class Media:
181+
js = ("dashboards/vendor/js/plotly.min.js",)
182+
183+
184+
class ChartSerializer(ModelDataMixin, PlotlyChartSerializer):
185+
"""
186+
Default chart serializer to read data from a django model
187+
and serialize it to something plotly js can render
188+
"""
189+
190+
class Meta(ModelDataMixin.Meta, PlotlyChartSerializer.Meta):
191+
pass
192+
193+
_meta: Type["ChartSerializer.Meta"]

dashboards/component/gauge/gauge.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ class Gauge(Component):
1818
title: str = ""
1919
value: Optional[GaugeValue] = None
2020
template_name: str = "dashboards/components/gauge/gauge.html"
21+
22+
class Media:
23+
js = ("dashboards/vendor/js/gauge.min.js",)

0 commit comments

Comments
 (0)