11import json
2- from typing import Any , Dict , List , Optional
2+ from typing import Any , Dict , List , Optional , Type
33
44from 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
69import pandas as pd
710import plotly .graph_objs as go
811
912from 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" ]
0 commit comments