Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamic filtering #24

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions dashboards/component/chart/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from dashboards.meta import ClassWithMeta

from dashboards.component.filters import Filter

class ModelDataMixin:
"""
Expand Down Expand Up @@ -182,12 +183,13 @@ class Media:


class ChartSerializer(ModelDataMixin, PlotlyChartSerializer):
"""
Default chart serializer to read data from a django model
and serialize it to something plotly js can render
"""

class Meta(ModelDataMixin.Meta, PlotlyChartSerializer.Meta):
pass
filter_component = Filter # Added the Filter component here
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want to set a default here, it should just be a type annotation and default to None as many use cases wont want a filter on a chart

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should also be the FilterSet that gets passed into the filter component rather than the filter component itself

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed from form


_meta: Type["ChartSerializer.Meta"]

def get_queryset(self, *args, **kwargs):
filter_instance = self._meta.filter_component(model=self.Meta.model)
queryset = super().get_queryset(*args, **kwargs)
queryset = filter_instance.apply_filters(queryset, self.context['request'].GET)
return queryset
93 changes: 93 additions & 0 deletions dashboards/component/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Literal, Optional, Type

from django.http import HttpRequest
from django.urls import reverse
from django.db import models
from django_filters import FilterSet, CharFilter

from .. import config
from ..forms import DashboardForm
from ..types import ValueData
from .base import Component, value_render_encoder

@dataclass
class FilterData:
action: str
form: Dict[str, Any]
method: str
dependents: Optional[List[str]] = None

@dataclass
class Filter(Component):
template_name: Optional[str] = None
model: Optional[Type[models.Model]] = None
method: Literal["get", "post"] = "get"
submit_url: Optional[str] = None
filter_fields: Optional[Dict[str, Any]] = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it makes more sense to just store the filter set class here rather than an arbitrary dict, this would mean all fields wouldn't need to be created as CharFilter

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably also need to validate that the filter is set and raise a ConfigurationError if not

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have updated it

form: Optional[Type[DashboardForm]] = None # Add this line

def __post_init__(self):
default_css_classes = config.Config().DASHBOARDS_COMPONENT_CLASSES["Filter"]

if self.css_classes and isinstance(self.css_classes, str):
self.css_classes = {"filter": self.css_classes}

if isinstance(default_css_classes, dict) and isinstance(self.css_classes, dict):
default_css_classes.update(self.css_classes)

self.css_classes = default_css_classes

def get_submit_url(self):
if not self.dashboard:
raise Exception("Dashboard is not set on Filter Component")

if self.submit_url:
return self.submit_url

args = [
self.dashboard._meta.app_label,
self.dashboard_class,
self.key,
]

if self.object:
args.insert(2, getattr(self.object, self.dashboard._meta.lookup_field))

return reverse("dashboards:filter_component", args=args)

def get_filter_form(self) -> Type[FilterSet]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be get_filterset

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed as per feedback

class DynamicFilterSet(FilterSet):
class Meta:
model = self.model

for field_name, field_config in self.filter_fields.items():
DynamicFilterSet.base_filters[field_name] = CharFilter(**field_config)

return DynamicFilterSet

def get_value(
self,
request: HttpRequest = None,
call_deferred=False,
filters: Optional[Dict[str, Any]] = None,
) -> ValueData:
if not self.model or not self.filter_fields or not self.form: # Add form check
raise NotImplementedError("Model, filter_fields, and form must be specified for Filter Component")

filter_form = self.get_filter_form()

# Apply filter data to the form
filter_instance = filter_form(request.GET, queryset=self.model.objects.all())
queryset = filter_instance.qs

filter_data = FilterData(
method=self.method,
form=asdict(filter_instance.form, dict_factory=value_render_encoder),
action=self.get_submit_url(),
dependents=self.dependents,
)

value = asdict(filter_data, dict_factory=value_render_encoder)

return value
28 changes: 19 additions & 9 deletions dashboards/component/form.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from dataclasses import asdict, dataclass
from typing import Any, Dict, Literal, Optional, Type
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Literal, Optional, Type

from django.http import HttpRequest
from django.urls import reverse
Expand All @@ -8,15 +8,14 @@
from ..forms import DashboardForm
from ..types import ValueData
from .base import Component, value_render_encoder

from dashboards.component.filters import Filter

@dataclass
class FormData:
action: list[str]
form: list[dict[str, Any]]
action: str
form: Dict[str, Any]
method: str
dependents: Optional[list[str]] = None

dependents: Optional[List[str]] = None

@dataclass
class Form(Component):
Expand All @@ -25,12 +24,16 @@ class Form(Component):
method: Literal["get", "post"] = "get"
trigger: Literal["change", "submit"] = "change"
submit_url: Optional[str] = None
# Add these attributes for the GenericFilter
filter_data: Optional[List[Dict[str, Any]]] = None
filter_fields: Optional[Dict[str, Any]] = None

def __post_init__(self):
default_css_classes = config.Config().DASHBOARDS_COMPONENT_CLASSES["Form"]

# make sure css_classes is a dict as this is what form template requires
if self.css_classes and isinstance(self.css_classes, str):
# if sting assume this is form class
# if string assume this is form class
self.css_classes = {"form": self.css_classes}

# update defaults with any css classes which have been passed in
Expand Down Expand Up @@ -77,6 +80,13 @@ def get_form(self, request: HttpRequest = None) -> DashboardForm:
data = request.GET

form = self.form(data=data)

# Create and apply the GenericFilter if filter_data and filter_fields are provided
if self.filter_data and self.filter_fields and self.model:
filter_instance = Filter(data=self.filter_data, fields=self.filter_fields)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be misunderstanding something but why are we applying a filter here, the form shouldn't be doing any filtering right? it will just be providing the ability to submit data?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filtering removed from form

queryset = filter_instance.filter(self.model.objects.all(), None, None)
form.fields['filter_field'].queryset = queryset

return form

def get_value(
Expand All @@ -88,7 +98,7 @@ def get_value(
form = self.get_form(request=request)
form_data = FormData(
method=self.method,
form=form,
form=asdict(form, dict_factory=value_render_encoder),
action=self.get_submit_url(),
dependents=self.dependents,
)
Expand Down
53 changes: 30 additions & 23 deletions dashboards/component/stat/serializers.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any, Optional, Type
from typing import Optional, Type

from dashboards.component.filters import Filter
from dashboards.meta import ClassWithMeta
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Aggregate, Model, QuerySet
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.timesince import timesince

import asset_definitions

from dashboards.meta import ClassWithMeta

from dataclasses import dataclass, field
from datetime import timedelta, datetime
from typing import Any

@dataclass
class StatSerializerData:
Expand Down Expand Up @@ -68,7 +68,7 @@ def annotated_field_name(self) -> str:
return f"{self._meta.annotation.name.lower()}_{self._meta.annotation_field}"

def aggregate_queryset(self, queryset) -> QuerySet:
# apply aggregation to queryset to get single value
# apply aggregation to queryset to get a single value
queryset = queryset.aggregate(
**{
self.annotated_field_name: self._meta.annotation(
Expand All @@ -79,7 +79,7 @@ def aggregate_queryset(self, queryset) -> QuerySet:

return queryset

def get_queryset(self, *args, **kwargs) -> QuerySet:
def get_queryset(self, filter_component: Optional[Filter] = None, *args, **kwargs) -> QuerySet:
if self._meta.model is not None:
queryset = self._meta.model._default_manager.all()
else:
Expand All @@ -89,10 +89,15 @@ def get_queryset(self, *args, **kwargs) -> QuerySet:
"%(self)s.get_queryset()." % {"self": self.__class__.__name__}
)

if filter_component:
# Apply filter data to the form
filter_instance = filter_component.get_filter_form()(kwargs.get('request').GET, queryset=queryset)
queryset = filter_instance.qs

return queryset

@classmethod
def serialize(cls, **kwargs) -> StatSerializerData:
def serialize(cls, filter_component: Optional[Filter] = None, **kwargs) -> StatSerializerData:
raise NotImplementedError


Expand All @@ -102,24 +107,24 @@ class StatSerializer(BaseStatSerializer, asset_definitions.MediaDefiningClass):
class Media:
js = ("https://unpkg.com/feather-icons", "dashboards/js/icons.js")

def get_value(self) -> Any:
queryset = self.get_queryset()
def get_value(self, filter_component: Optional[Filter] = None) -> Any:
queryset = self.get_queryset(filter_component)
queryset = self.aggregate_queryset(queryset)
return queryset[self.annotated_field_name]

@classmethod
def serialize(cls, **kwargs) -> StatSerializerData:
def serialize(cls, filter_component: Optional[Filter] = None, **kwargs) -> StatSerializerData:
self = cls()

return StatSerializerData(
title=self._meta.verbose_name,
value=self.get_value(),
value=self.get_value(filter_component),
unit=self._meta.unit,
)

@classmethod
def render(cls, **kwargs) -> str:
value = cls.serialize(**kwargs)
def render(cls, filter_component: Optional[Filter] = None, **kwargs) -> str:
value = cls.serialize(filter_component, **kwargs)
context = {
"rendered_value": value,
**kwargs,
Expand Down Expand Up @@ -157,13 +162,13 @@ def get_change_period(self):
return ""

@classmethod
def serialize(cls, **kwargs) -> StatSerializerData:
def serialize(cls, filter_component: Optional[Filter] = None, **kwargs) -> StatSerializerData:
self = cls()

return StatSerializerData(
title=self._meta.verbose_name,
value=self.get_value(),
previous=self.get_previous(),
value=self.get_value(filter_component),
previous=self.get_previous(filter_component),
unit=self._meta.unit,
change_period=self.get_change_period(),
)
Expand All @@ -175,24 +180,26 @@ def date_field(self) -> str:

return f"{self._meta.date_field_name}__lte"

def get_value(self) -> Any:
queryset = self.get_queryset()
def get_value(self, filter_component: Optional[Filter] = None) -> Any:
queryset = self.get_queryset(filter_component)
date_current = self.get_date_current()
# filter on date if we have it

if date_current:
# filter on date if we have it
queryset = queryset.filter(**{self.date_field: date_current})

queryset = self.aggregate_queryset(queryset)

return queryset[self.annotated_field_name]

def get_previous(self) -> Any:
def get_previous(self, filter_component: Optional[Filter] = None) -> Any:
data_previous = self.get_date_previous()

# only return previous if we have a previous date to compare
if data_previous is None:
return None

queryset = self.get_queryset()
queryset = self.get_queryset(filter_component)
queryset = queryset.filter(**{self.date_field: data_previous})
queryset = self.aggregate_queryset(queryset)

Expand Down
9 changes: 7 additions & 2 deletions dashboards/component/table/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from dashboards.meta import ClassWithMeta

from .mixins import TableDataProcessorMixin
from dashboards.component.filters import Filter


@dataclass
Expand Down Expand Up @@ -163,6 +164,7 @@ class Meta:
first_as_absolute_url = False
force_lower = True
model: Optional[Model] = None
filter_component = Filter # Add the filter component here

def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Expand All @@ -171,7 +173,10 @@ def __init_subclass__(cls, **kwargs):
raise ImproperlyConfigured("Table must have columns defined")

def get_data(self, *args, **kwargs) -> QuerySet:
return self.get_queryset(*args, **kwargs)
filter_instance = self._meta.filter_component(model=self._meta.model)
queryset = self.get_queryset(*args, **kwargs)
queryset = filter_instance.apply_filters(queryset, self.context['request'].GET)
return queryset

def get_queryset(self, *args, **kwargs) -> QuerySet:
if self._meta.model is not None:
Expand All @@ -183,4 +188,4 @@ def get_queryset(self, *args, **kwargs) -> QuerySet:
"%(self)s.get_queryset()." % {"self": self.__class__.__name__}
)

return queryset
return queryset
2 changes: 1 addition & 1 deletion demos/dashboard/demo/kitchensink/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,4 @@ class ExampleMapSerializer(ChoroplethMapSerializer):
color = [1, 2, 3]

class Meta:
title = "Example Choroplet Map"
title = "Example Choroplet Map"
2 changes: 1 addition & 1 deletion demos/dashboard/demo/kitchensink/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,4 @@ class Meta:
date_field_name = "date_joined"
previous_delta = timedelta(days=7)
title = "New Users"
unit = " people"
unit = " people"
Loading
Loading