-
Notifications
You must be signed in to change notification settings - Fork 534
/
models.py
242 lines (206 loc) · 8.37 KB
/
models.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
from dj.choices import Choices
from django.contrib.contenttypes.models import ContentType
from django.db import connection, models
from django.db.models import Case, Count, IntegerField, Max, Q, Sum, Value, When
from django_extensions.db.fields.json import JSONField
from ralph.dashboards.filter_parser import FilterParser
from ralph.dashboards.renderers import HorizontalBar, PieChart, VerticalBar
from ralph.lib.mixins.models import (
AdminAbsoluteUrlMixin,
NamedMixin,
TimeStampMixin
)
class Dashboard(
AdminAbsoluteUrlMixin,
NamedMixin,
TimeStampMixin,
models.Model
):
active = models.BooleanField(default=True)
description = models.CharField('description', max_length=250, blank=True)
graphs = models.ManyToManyField('Graph', blank=True)
interval = models.PositiveSmallIntegerField(default=60)
def ratio_handler(queryset, series):
if not isinstance(series, list):
raise ValueError('Ratio aggregation requires series to be list')
if len(series) != 2:
raise ValueError(
'Ratio aggregation requires series to be list of size 2'
)
# postgres does not support Sum with boolean field so we need to use
# Case-When with integer values here
return (
Sum(Case(
When(Q(**{series[0]: True}), then=Value(1)),
When(Q(**{series[0]: False}), then=Value(0)),
default=Value(0),
output_field=IntegerField()
)) * 100.0 / Count(series[1])
)
class AggregateType(Choices):
_ = Choices.Choice
aggregate_count = _('Count').extra(aggregate_func=Count)
aggregate_max = _('Max').extra(aggregate_func=Max)
aggregate_sum = _('Sum').extra(aggregate_func=Sum)
aggregate_ratio = _('Ratio').extra(
aggregate_func=Count, handler=ratio_handler
)
class ChartType(Choices):
# NOTE: append new type
_ = Choices.Choice
vertical_bar = _('Verical Bar').extra(renderer=VerticalBar)
horizontal_bar = _('Horizontal Bar').extra(renderer=HorizontalBar)
pie_chart = _('Pie Chart').extra(renderer=PieChart)
def _to_pair(text, sep):
split = text.split(sep)
if len(split) == 1:
orig_label, label = split[0], split[0]
elif len(split) == 2:
orig_label, label = split[0], split[1]
else:
raise ValueError("Only one group supported")
return orig_label, label
class GroupingLabel:
"""
Adds grouping-by-year feature to query based on `label_group`
"""
sep = '|'
date_fields = ['year', 'month', 'day', 'hour', 'minute', 'second']
date_format = ('%Y-', '%m', '-%d', ' %H:', '%i', ':%s')
def __init__(self, connection, label_group):
self.connection = connection
self.orig_label, self.label = self.parse(label_group)
def parse(self, label_group):
return _to_pair(label_group, self.sep)
@property
def has_group(self):
return self.orig_label != self.label
def _group_by_part_of_date(self, date_part):
field_name = self.orig_label.split('__')[-1]
return self.connection.ops.date_trunc_sql(date_part, field_name)
def apply_grouping(self, queryset):
if self.has_group:
if self.label in self.date_fields:
queryset = queryset.extra({
self.label: self._group_by_part_of_date(self.label)
})
else:
queryset = queryset.extra({
self.label: getattr(self, 'group_' + self.label)()
})
return queryset
def _format_part_of_date(self, value):
i = self.date_fields.index(self.label) + 1
format_str = ''.join([f for f in self.date_format[:i]])
return value.strftime(format_str)
def format_label(self, value):
if self.has_group:
value = self._format_part_of_date(value)
return str(value)
class Graph(AdminAbsoluteUrlMixin, NamedMixin, TimeStampMixin, models.Model):
description = models.CharField('description', max_length=250, blank=True)
model = models.ForeignKey(ContentType)
aggregate_type = models.PositiveIntegerField(choices=AggregateType())
chart_type = models.PositiveIntegerField(choices=ChartType())
params = JSONField(blank=True)
active = models.BooleanField(default=True)
push_to_statsd = models.BooleanField(
default=False,
help_text='Push graph\'s data to statsd.'
)
def pop_annotate_filters(self, filters):
annotate_filters = {}
for key in list(filters.keys()):
if key.startswith('series'):
annotate_filters.update({key: filters.pop(key)})
return annotate_filters
def apply_parital_filtering(self, queryset):
filters = self.params.get('filters', None)
excludes = self.params.get('excludes', None)
if filters:
queryset = FilterParser(queryset, filters).get_queryset()
if excludes:
queryset = FilterParser(
queryset, excludes, exclude_mode=True
).get_queryset()
return queryset
@property
def has_grouping(self):
labels = self.params.get('labels', '')
grouping_label = GroupingLabel(connection, labels)
return grouping_label.has_group
def apply_limit(self, queryset):
limit = self.params.get('limit', None)
return queryset[:limit]
def apply_sort(self, queryset):
order = self.params.get('sort', None)
if order:
return queryset.order_by(order)
return queryset
def _unpack_series(self, series):
if not isinstance(series, str):
raise ValueError('Series should be string')
series_field, fn = _to_pair(series, '|')
if (series_field != fn) and (fn != 'distinct'):
raise ValueError(
"Series supports Only `distinct` (you put '{}')".format(fn) # noqa
)
if not series_field:
raise ValueError("Field `series` can't be empty")
return series_field, fn
def get_aggregation(self):
aggregate_type = AggregateType.from_id(self.aggregate_type)
aggregate_func = aggregate_type.aggregate_func
# aggregate choice might have defined custom handler
handler = getattr(
aggregate_type, 'handler', self._default_aggregation_handler
)
return handler(aggregate_func, self.params.get('series', ''))
def _default_aggregation_handler(self, aggregate_func, series):
series_field, fn_name = self._unpack_series(series)
aggregate_fn_kwargs = {}
if fn_name == "distinct":
if self.aggregate_type != AggregateType.aggregate_count.id:
raise ValueError(
"{} can by only used with {}".format(
AggregateType.from_id(self.aggregate_type).desc,
fn_name,
)
)
aggregate_fn_kwargs['distinct'] = True
return aggregate_func(series_field, **aggregate_fn_kwargs)
def build_queryset(self):
model = self.model.model_class()
model_manager = model._default_manager
queryset = model_manager.all()
grouping_label = GroupingLabel(connection, self.params['labels'])
queryset = grouping_label.apply_grouping(queryset)
# pop filters which are applied on annotated queryset
annotate_filters = self.pop_annotate_filters(
self.params.get('filters', {})
)
queryset = self.apply_parital_filtering(queryset)
queryset = queryset.values(
grouping_label.label
).annotate(
series=self.get_aggregation(),
)
if annotate_filters:
queryset = queryset.filter(**annotate_filters)
queryset = self.apply_sort(queryset)
queryset = self.apply_limit(queryset)
return queryset
def get_data(self):
queryset = self.build_queryset()
grouping_label = GroupingLabel(connection, self.params['labels'])
label = grouping_label.label
return {
'labels': [grouping_label.format_label(q[label]) for q in queryset],
'series': [int(q['series']) for q in queryset],
}
def render(self, **context):
chart_type = ChartType.from_id(self.chart_type)
renderer = getattr(chart_type, 'renderer', None)
if not renderer:
raise RuntimeError('Wrong renderer.')
return renderer(self).render(context)