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.
File without changes
@@ -0,0 +1 @@
1
+ # Register your models here.
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class BaseConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "django_htmx_base"
7
+ label = "base"
@@ -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.
@@ -0,0 +1,9 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ app_name = "base"
6
+
7
+ urlpatterns = [
8
+ path("ping/", views.ping, name="ping"),
9
+ ]
@@ -0,0 +1,7 @@
1
+ from django.shortcuts import render
2
+ from django.views.decorators.http import require_POST
3
+
4
+
5
+ @require_POST
6
+ def ping(request):
7
+ return render(request, "partials/ping_result.html", {"ok": True})