django-htmx-base 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_htmx_base/__init__.py +0 -0
- django_htmx_base/admin.py +1 -0
- django_htmx_base/apps.py +7 -0
- django_htmx_base/forms.py +20 -0
- django_htmx_base/migrations/__init__.py +0 -0
- django_htmx_base/models.py +210 -0
- django_htmx_base/routers.py +157 -0
- django_htmx_base/tests.py +1 -0
- django_htmx_base/urls.py +9 -0
- django_htmx_base/views.py +7 -0
- django_htmx_base/viewsets.py +712 -0
- django_htmx_base/widgets.py +42 -0
- django_htmx_base-0.1.2.dist-info/METADATA +214 -0
- django_htmx_base-0.1.2.dist-info/RECORD +17 -0
- django_htmx_base-0.1.2.dist-info/WHEEL +5 -0
- django_htmx_base-0.1.2.dist-info/licenses/LICENSE +21 -0
- django_htmx_base-0.1.2.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Register your models here.
|
django_htmx_base/apps.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# django
|
|
2
|
+
from django import forms
|
|
3
|
+
|
|
4
|
+
# widgets
|
|
5
|
+
from django_htmx_base.widgets import WidgetStylerMixin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseForm(WidgetStylerMixin, forms.Form):
|
|
9
|
+
def __init__(self, *args, **kwargs):
|
|
10
|
+
super().__init__(*args, **kwargs)
|
|
11
|
+
self.apply_widget_styles()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseModelForm(WidgetStylerMixin, forms.ModelForm):
|
|
15
|
+
class Meta:
|
|
16
|
+
fields = "__all__"
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args, **kwargs):
|
|
19
|
+
super().__init__(*args, **kwargs)
|
|
20
|
+
self.apply_widget_styles()
|
|
File without changes
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# standard
|
|
2
|
+
import csv
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from io import StringIO
|
|
5
|
+
|
|
6
|
+
# django
|
|
7
|
+
from django.db import models
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FilterInputType(StrEnum):
|
|
11
|
+
CHECKBOX = "checkbox"
|
|
12
|
+
DATE = "date"
|
|
13
|
+
DATETIME_LOCAL = "datetime-local"
|
|
14
|
+
MULTISELECT = "multiselect"
|
|
15
|
+
NUMBER = "number"
|
|
16
|
+
RADIO = "radio"
|
|
17
|
+
RANGE = "range"
|
|
18
|
+
SELECT = "select"
|
|
19
|
+
TEXT = "text"
|
|
20
|
+
TIME = "time"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def BaseField(base_field_class, **kwargs): # noqa: N802
|
|
24
|
+
sortable = kwargs.pop("sortable", False)
|
|
25
|
+
partial = kwargs.pop("partial", None)
|
|
26
|
+
css_class = kwargs.pop("css_class", "")
|
|
27
|
+
filtrable = kwargs.pop("filtrable", False)
|
|
28
|
+
filter_input_type = FilterInputType(
|
|
29
|
+
kwargs.pop("filter_input_type", FilterInputType.TEXT)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
field = base_field_class(**kwargs)
|
|
33
|
+
field.sortable = sortable
|
|
34
|
+
field.partial = partial
|
|
35
|
+
field.css_class = css_class
|
|
36
|
+
field.filtrable = filtrable
|
|
37
|
+
field.filter_input_type = filter_input_type
|
|
38
|
+
|
|
39
|
+
return field
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BaseModel(models.Model):
|
|
43
|
+
"""
|
|
44
|
+
Abstract base for all models
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
# fields
|
|
48
|
+
created_at = BaseField(
|
|
49
|
+
models.DateTimeField, auto_now_add=True, editable=False, sortable=True
|
|
50
|
+
)
|
|
51
|
+
updated_at = BaseField(models.DateTimeField, auto_now=True, editable=False)
|
|
52
|
+
is_active = BaseField(
|
|
53
|
+
models.BooleanField,
|
|
54
|
+
default=True,
|
|
55
|
+
filtrable=True,
|
|
56
|
+
filter_input_type=FilterInputType.SELECT,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# view attributes
|
|
60
|
+
_display_fields = ["id", "created_at", "is_active"]
|
|
61
|
+
_downloadable = True
|
|
62
|
+
|
|
63
|
+
class Meta:
|
|
64
|
+
abstract = True
|
|
65
|
+
ordering = ("-created_at",)
|
|
66
|
+
|
|
67
|
+
def __str__(self) -> str:
|
|
68
|
+
return f"{self.__class__.__name__}(id={getattr(self, 'id', None)})"
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def get_filtrable_fields(cls):
|
|
72
|
+
"""
|
|
73
|
+
Returns context-ready filter metadata for each filtrable field.
|
|
74
|
+
"""
|
|
75
|
+
return [
|
|
76
|
+
field
|
|
77
|
+
for field in cls.get_display_fields()
|
|
78
|
+
if getattr(field, "filtrable", False)
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def get_filters_objects(cls):
|
|
83
|
+
filters = []
|
|
84
|
+
|
|
85
|
+
for field in cls.get_filtrable_fields():
|
|
86
|
+
filter_config = {
|
|
87
|
+
"field": field.name,
|
|
88
|
+
"filter_input_type": getattr(
|
|
89
|
+
field, "filter_input_type", FilterInputType.TEXT
|
|
90
|
+
).value,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if hasattr(field, "choices") and field.choices:
|
|
94
|
+
filter_config["options"] = [
|
|
95
|
+
{"value": value, "label": label}
|
|
96
|
+
for value, label in field.choices
|
|
97
|
+
if value not in (None, "")
|
|
98
|
+
]
|
|
99
|
+
elif isinstance(field, models.BooleanField):
|
|
100
|
+
filter_config["options"] = [
|
|
101
|
+
{"value": "True", "label": "True"},
|
|
102
|
+
{"value": "False", "label": "False"},
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
filters.append(filter_config)
|
|
106
|
+
|
|
107
|
+
return filters
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def filters(self):
|
|
111
|
+
return self.get_filtrable_fields()
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def filters_objects(self):
|
|
115
|
+
return self.get_filters_objects()
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def sortable_fields(cls):
|
|
119
|
+
"""
|
|
120
|
+
Returns a list of fields that are sortable.
|
|
121
|
+
"""
|
|
122
|
+
return [
|
|
123
|
+
field.name
|
|
124
|
+
for field in cls.get_display_fields()
|
|
125
|
+
if getattr(field, "sortable", False)
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def get_display_fields(cls):
|
|
130
|
+
return [
|
|
131
|
+
field
|
|
132
|
+
for field in cls._meta.get_fields()
|
|
133
|
+
if field.name in cls._display_fields
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def display_fields(self):
|
|
138
|
+
return self.get_display_fields()
|
|
139
|
+
|
|
140
|
+
def as_list(self):
|
|
141
|
+
"""Ordered values for this instance, matching ``display_fields`` order."""
|
|
142
|
+
values = []
|
|
143
|
+
for field in self.display_fields:
|
|
144
|
+
if getattr(field, "choices", None):
|
|
145
|
+
value = getattr(self, f"get_{field.name}_display")()
|
|
146
|
+
else:
|
|
147
|
+
value = getattr(self, field.name)
|
|
148
|
+
|
|
149
|
+
values.append(value)
|
|
150
|
+
|
|
151
|
+
return values
|
|
152
|
+
|
|
153
|
+
def as_field_values_objects_list(self):
|
|
154
|
+
"""Ordered cells for this instance, matching ``display_fields`` order."""
|
|
155
|
+
field_objects = []
|
|
156
|
+
|
|
157
|
+
for field in self.display_fields:
|
|
158
|
+
if getattr(field, "choices", None):
|
|
159
|
+
value = getattr(self, f"get_{field.name}_display")()
|
|
160
|
+
else:
|
|
161
|
+
value = getattr(self, field.name)
|
|
162
|
+
|
|
163
|
+
field_objects.append(
|
|
164
|
+
{
|
|
165
|
+
"field": field.name,
|
|
166
|
+
"value": value,
|
|
167
|
+
"partial": getattr(field, "partial", None),
|
|
168
|
+
"class": getattr(field, "css_class", ""),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return field_objects
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def to_csv(cls, queryset):
|
|
176
|
+
output = StringIO(newline="")
|
|
177
|
+
writer = csv.writer(output)
|
|
178
|
+
writer.writerow(field.name for field in cls.get_display_fields())
|
|
179
|
+
|
|
180
|
+
for object in queryset:
|
|
181
|
+
writer.writerow(object.as_list())
|
|
182
|
+
|
|
183
|
+
return output.getvalue()
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def as_field_names_objects_list(cls):
|
|
187
|
+
columns = []
|
|
188
|
+
|
|
189
|
+
for field in cls.get_display_fields():
|
|
190
|
+
column = {
|
|
191
|
+
"field": field.name,
|
|
192
|
+
"sortable": getattr(field, "sortable", False),
|
|
193
|
+
"filtrable": getattr(field, "filtrable", False),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
partial = getattr(field, "partial", None)
|
|
197
|
+
if partial:
|
|
198
|
+
column["partial"] = partial
|
|
199
|
+
|
|
200
|
+
css_class = getattr(field, "css_class", None)
|
|
201
|
+
if css_class:
|
|
202
|
+
column["class"] = css_class
|
|
203
|
+
|
|
204
|
+
columns.append(column)
|
|
205
|
+
|
|
206
|
+
return columns
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def is_downloadable(cls):
|
|
210
|
+
return cls._downloadable
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
2
|
+
from django.urls import path
|
|
3
|
+
|
|
4
|
+
from django_htmx_base.viewsets import HtmxAction
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Route:
|
|
8
|
+
def __init__(self, url, mapping, name, detail):
|
|
9
|
+
self.url = url
|
|
10
|
+
self.mapping = mapping
|
|
11
|
+
self.name = name
|
|
12
|
+
self.detail = detail
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HtmxRouter:
|
|
16
|
+
"""
|
|
17
|
+
Generate CRUD URL patterns for HtmxViewSet classes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
routes = [
|
|
21
|
+
Route(
|
|
22
|
+
url="",
|
|
23
|
+
mapping={"get": HtmxAction.LIST},
|
|
24
|
+
name=HtmxAction.LIST,
|
|
25
|
+
detail=False,
|
|
26
|
+
),
|
|
27
|
+
Route(
|
|
28
|
+
url=HtmxAction.CREATE,
|
|
29
|
+
mapping={"get": HtmxAction.CREATE, "post": HtmxAction.CREATE},
|
|
30
|
+
name=HtmxAction.CREATE,
|
|
31
|
+
detail=False,
|
|
32
|
+
),
|
|
33
|
+
Route(
|
|
34
|
+
url="{pk}",
|
|
35
|
+
mapping={"get": HtmxAction.DETAIL},
|
|
36
|
+
name=HtmxAction.DETAIL,
|
|
37
|
+
detail=True,
|
|
38
|
+
),
|
|
39
|
+
Route(
|
|
40
|
+
url=f"{{pk}}/{HtmxAction.EDIT}",
|
|
41
|
+
mapping={
|
|
42
|
+
"get": HtmxAction.CREATE,
|
|
43
|
+
"post": HtmxAction.EDIT,
|
|
44
|
+
"put": HtmxAction.EDIT,
|
|
45
|
+
"patch": HtmxAction.EDIT,
|
|
46
|
+
},
|
|
47
|
+
name=HtmxAction.EDIT,
|
|
48
|
+
detail=True,
|
|
49
|
+
),
|
|
50
|
+
Route(
|
|
51
|
+
url=f"{{pk}}/{HtmxAction.DELETE}",
|
|
52
|
+
mapping={
|
|
53
|
+
"get": HtmxAction.CREATE,
|
|
54
|
+
"post": HtmxAction.DESTROY,
|
|
55
|
+
"delete": HtmxAction.DESTROY,
|
|
56
|
+
},
|
|
57
|
+
name=HtmxAction.DELETE,
|
|
58
|
+
detail=True,
|
|
59
|
+
),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
def __init__(self):
|
|
63
|
+
self.registry = []
|
|
64
|
+
|
|
65
|
+
def register(self, prefix, viewset, basename=None):
|
|
66
|
+
if basename is None:
|
|
67
|
+
basename = self.get_default_basename(viewset)
|
|
68
|
+
|
|
69
|
+
viewset.router = self
|
|
70
|
+
|
|
71
|
+
self.registry.append((prefix.strip("/"), viewset, basename))
|
|
72
|
+
|
|
73
|
+
def get_default_basename(self, viewset):
|
|
74
|
+
model = getattr(viewset, "model", None)
|
|
75
|
+
if model is not None:
|
|
76
|
+
return model._meta.model_name
|
|
77
|
+
|
|
78
|
+
queryset = getattr(viewset, "queryset", None)
|
|
79
|
+
if queryset is not None and hasattr(queryset, "model"):
|
|
80
|
+
return queryset.model._meta.model_name
|
|
81
|
+
|
|
82
|
+
raise ImproperlyConfigured(
|
|
83
|
+
"%(cls)s is missing a basename. Pass basename=... to register(), "
|
|
84
|
+
"or define %(cls)s.model or %(cls)s.queryset." % {"cls": viewset.__name__}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def urls(self):
|
|
89
|
+
urls = []
|
|
90
|
+
|
|
91
|
+
for prefix, viewset, basename in self.registry:
|
|
92
|
+
pk = self.get_pk_path(viewset)
|
|
93
|
+
|
|
94
|
+
for route in self.get_routes(viewset):
|
|
95
|
+
mapping = self.get_method_map(viewset, route.mapping)
|
|
96
|
+
if not mapping:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
route_url = route.url.format(pk=pk)
|
|
100
|
+
url = self.build_url(prefix, route_url)
|
|
101
|
+
view = viewset.as_view(
|
|
102
|
+
mapping,
|
|
103
|
+
basename=basename,
|
|
104
|
+
route_detail=route.detail,
|
|
105
|
+
route_action=route.name,
|
|
106
|
+
)
|
|
107
|
+
urls.append(path(url, view, name=f"{basename}-{route.name}"))
|
|
108
|
+
|
|
109
|
+
return urls
|
|
110
|
+
|
|
111
|
+
def get_routes(self, viewset):
|
|
112
|
+
return [*self.routes, *self.get_extra_routes(viewset)]
|
|
113
|
+
|
|
114
|
+
def get_extra_routes(self, viewset):
|
|
115
|
+
if not hasattr(viewset, "get_extra_actions"):
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
routes = []
|
|
119
|
+
|
|
120
|
+
for extra_action in viewset.get_extra_actions():
|
|
121
|
+
url_path = getattr(extra_action, "url_path", None)
|
|
122
|
+
url = url_path or extra_action.__name__
|
|
123
|
+
|
|
124
|
+
if extra_action.detail:
|
|
125
|
+
url = "{pk}/%s" % url
|
|
126
|
+
|
|
127
|
+
routes.append(
|
|
128
|
+
Route(
|
|
129
|
+
url=url,
|
|
130
|
+
mapping=extra_action.mapping,
|
|
131
|
+
name=extra_action.url_name,
|
|
132
|
+
detail=extra_action.detail,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return routes
|
|
137
|
+
|
|
138
|
+
def get_pk_path(self, viewset):
|
|
139
|
+
pk_url_kwarg = getattr(viewset, "pk_url_kwarg", "pk")
|
|
140
|
+
return "<int:%s>" % pk_url_kwarg
|
|
141
|
+
|
|
142
|
+
def get_method_map(self, viewset, mapping):
|
|
143
|
+
return {
|
|
144
|
+
method: action
|
|
145
|
+
for method, action in mapping.items()
|
|
146
|
+
if hasattr(viewset, action)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def build_url(self, prefix, route_url):
|
|
150
|
+
if prefix and route_url:
|
|
151
|
+
url = f"{prefix}/{route_url}"
|
|
152
|
+
else:
|
|
153
|
+
url = prefix or route_url
|
|
154
|
+
|
|
155
|
+
if url:
|
|
156
|
+
return url + "/"
|
|
157
|
+
return ""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Create your tests here.
|
django_htmx_base/urls.py
ADDED