Skip to content

Commit 16651cb

Browse files
Merge pull request wildfish#12 from wildfish/feature/stat-serializer
Feature/stat serializer
2 parents 14fa116 + 40ed268 commit 16651cb

File tree

22 files changed

+844
-234
lines changed

22 files changed

+844
-234
lines changed

dashboards/component/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ def render_value(self, context: Context, call_deferred: bool = False) -> str:
198198
request=request,
199199
filters=filters,
200200
object=self.object,
201+
icon=self.icon,
201202
css_classes=self.css_classes,
202203
is_deferred=self.is_deferred,
203204
defer_url=self.get_absolute_url(),
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .serializers import StatDateChangeSerializer, StatSerializer
2+
from .stat import Stat, StatData
3+
4+
5+
__all__ = ["Stat", "StatData", "StatSerializer", "StatDateChangeSerializer"]
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
from dataclasses import dataclass, field
2+
from datetime import timedelta
3+
from typing import Any, Optional, Type
4+
5+
from django.core.exceptions import ImproperlyConfigured
6+
from django.db.models import Aggregate, Model, QuerySet
7+
from django.template.loader import render_to_string
8+
from django.utils import timezone
9+
from django.utils.timesince import timesince
10+
11+
import asset_definitions
12+
13+
from dashboards.meta import ClassWithMeta
14+
15+
16+
@dataclass
17+
class StatSerializerData:
18+
title: str
19+
value: Any
20+
previous: Optional[Any] = None
21+
unit: Optional[str] = ""
22+
change_period: Optional[str] = ""
23+
change: Optional[float] = field(init=False)
24+
25+
def __post_init__(self):
26+
if self.previous is None:
27+
self.change = 0.0
28+
elif self.previous == 0:
29+
self.change = 100.0
30+
else:
31+
try:
32+
self.change = (self.value - self.previous) / self.previous * 100
33+
except (TypeError, ZeroDivisionError):
34+
self.change = None
35+
36+
37+
class BaseStatSerializer(ClassWithMeta):
38+
class Meta(ClassWithMeta.Meta):
39+
annotation_field: str
40+
annotation: Aggregate
41+
model: Optional[Model] = None
42+
title: Optional[str] = ""
43+
unit: Optional[str] = ""
44+
45+
_meta: Type["BaseStatSerializer.Meta"]
46+
47+
@classmethod
48+
def preprocess_meta(cls, current_class_meta):
49+
title = getattr(current_class_meta, "title", None)
50+
51+
if title and not hasattr(current_class_meta, "name"):
52+
current_class_meta.name = title
53+
54+
if title and not hasattr(current_class_meta, "verbose_name"):
55+
current_class_meta.verbose_name = title
56+
57+
return current_class_meta
58+
59+
@classmethod
60+
def postprocess_meta(cls, current_class_meta, resolved_meta_class):
61+
if not hasattr(resolved_meta_class, "title"):
62+
resolved_meta_class.title = resolved_meta_class.verbose_name
63+
64+
return resolved_meta_class
65+
66+
@property
67+
def annotated_field_name(self) -> str:
68+
return f"{self._meta.annotation.name.lower()}_{self._meta.annotation_field}"
69+
70+
def aggregate_queryset(self, queryset) -> QuerySet:
71+
# apply aggregation to queryset to get single value
72+
queryset = queryset.aggregate(
73+
**{
74+
self.annotated_field_name: self._meta.annotation(
75+
self._meta.annotation_field
76+
)
77+
}
78+
)
79+
80+
return queryset
81+
82+
def get_queryset(self, *args, **kwargs) -> QuerySet:
83+
if self._meta.model is not None:
84+
queryset = self._meta.model._default_manager.all()
85+
else:
86+
raise ImproperlyConfigured(
87+
"%(self)s is missing a QuerySet. Define "
88+
"%(self)s.model or override "
89+
"%(self)s.get_queryset()." % {"self": self.__class__.__name__}
90+
)
91+
92+
return queryset
93+
94+
@classmethod
95+
def serialize(cls, **kwargs) -> StatSerializerData:
96+
raise NotImplementedError
97+
98+
99+
class StatSerializer(BaseStatSerializer, asset_definitions.MediaDefiningClass):
100+
template_name: str = "dashboards/components/stat/stat.html"
101+
102+
class Media:
103+
js = ("https://unpkg.com/feather-icons", "dashboards/js/icons.js")
104+
105+
def get_value(self) -> Any:
106+
queryset = self.get_queryset()
107+
queryset = self.aggregate_queryset(queryset)
108+
return queryset[self.annotated_field_name]
109+
110+
@classmethod
111+
def serialize(cls, **kwargs) -> StatSerializerData:
112+
self = cls()
113+
114+
return StatSerializerData(
115+
title=self._meta.verbose_name,
116+
value=self.get_value(),
117+
unit=self._meta.unit,
118+
)
119+
120+
@classmethod
121+
def render(cls, **kwargs) -> str:
122+
value = cls.serialize(**kwargs)
123+
context = {
124+
"rendered_value": value,
125+
**kwargs,
126+
}
127+
128+
return render_to_string(cls.template_name, context)
129+
130+
131+
class StatDateChangeSerializer(StatSerializer):
132+
class Meta(BaseStatSerializer.Meta):
133+
date_field_name: Optional[str] = None
134+
previous_delta: Optional[timedelta] = None
135+
136+
_meta: Type["StatDateChangeSerializer.Meta"]
137+
138+
def get_date_current(self):
139+
# only do this if we are set-up with a date field
140+
if not self._meta.date_field_name:
141+
return None
142+
143+
return timezone.now()
144+
145+
def get_date_previous(self):
146+
if self._meta.date_field_name is None or self._meta.previous_delta is None:
147+
return None
148+
149+
return self.get_date_current() - self._meta.previous_delta
150+
151+
def get_change_period(self):
152+
now, previous = self.get_date_current(), self.get_date_previous()
153+
now += timedelta(seconds=1)
154+
if previous and now:
155+
return timesince(previous, now)
156+
157+
return ""
158+
159+
@classmethod
160+
def serialize(cls, **kwargs) -> StatSerializerData:
161+
self = cls()
162+
163+
return StatSerializerData(
164+
title=self._meta.verbose_name,
165+
value=self.get_value(),
166+
previous=self.get_previous(),
167+
unit=self._meta.unit,
168+
change_period=self.get_change_period(),
169+
)
170+
171+
@property
172+
def date_field(self) -> str:
173+
if not self._meta.date_field_name:
174+
return ""
175+
176+
return f"{self._meta.date_field_name}__lte"
177+
178+
def get_value(self) -> Any:
179+
queryset = self.get_queryset()
180+
date_current = self.get_date_current()
181+
# filter on date if we have it
182+
if date_current:
183+
queryset = queryset.filter(**{self.date_field: date_current})
184+
185+
queryset = self.aggregate_queryset(queryset)
186+
187+
return queryset[self.annotated_field_name]
188+
189+
def get_previous(self) -> Any:
190+
data_previous = self.get_date_previous()
191+
# only return previous if we have a previous date to compare
192+
if data_previous is None:
193+
return None
194+
195+
queryset = self.get_queryset()
196+
queryset = queryset.filter(**{self.date_field: data_previous})
197+
queryset = self.aggregate_queryset(queryset)
198+
199+
return queryset[self.annotated_field_name]

dashboards/component/stat/stat.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from dataclasses import dataclass, field
2+
from typing import Optional, Union
3+
4+
from dashboards.component import Component
5+
6+
7+
@dataclass
8+
class StatData:
9+
text: str
10+
change_by: Optional[float] = None
11+
change_by_text: Optional[str] = None
12+
sub_text: Optional[str] = ""
13+
change_by_display: Optional[Union[str, float]] = field(init=False)
14+
15+
def __post_init__(self):
16+
self.change_by_display = (
17+
self.change_by_text if self.change_by_text else self.change_by
18+
)
19+
20+
21+
@dataclass
22+
class Stat(Component):
23+
template_name: str = "dashboards/components/text/stat.html"
24+
href: Optional[str] = None

dashboards/component/text.py

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,13 @@
1-
from dataclasses import dataclass, field
2-
from typing import Optional, Union
1+
from dataclasses import dataclass
32

43
from .base import Component
4+
from .stat import Stat, StatData
5+
6+
7+
__all__ = ["Stat", "StatData", "Text"]
58

69

710
@dataclass
811
class Text(Component):
912
template_name: str = "dashboards/components/text/text.html"
1013
mark_safe: bool = False
11-
12-
13-
@dataclass
14-
class StatData:
15-
text: str
16-
change_by: Optional[float] = None
17-
change_by_text: Optional[str] = None
18-
sub_text: Optional[str] = ""
19-
change_by_display: Optional[Union[str, float]] = field(init=False)
20-
21-
def __post_init__(self):
22-
self.change_by_display = (
23-
self.change_by_text if self.change_by_text else self.change_by
24-
)
25-
26-
27-
@dataclass
28-
class Stat(Component):
29-
template_name: str = "dashboards/components/text/stat.html"
30-
href: Optional[str] = None

dashboards/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ def DASHBOARDS_COMPONENT_CLASSES(cls) -> Dict[str, Optional[Dict[str, str]]]:
5555
"stat": "stat",
5656
"icon": "stat__icon",
5757
"heading": "stat__heading",
58+
"title": "stat__title",
59+
"icon": "stat__icon",
60+
"body": "stat__body",
5861
"text": "stat__text",
62+
"change": "stat__change",
5963
}
6064
default_css_classes = {
6165
"Form": FORM_CLASSES,

0 commit comments

Comments
 (0)