accrete 0.0.36__py3-none-any.whl → 0.0.38__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.
- accrete/annotation.py +46 -0
- accrete/contrib/ui/__init__.py +5 -12
- accrete/contrib/ui/context.py +111 -218
- accrete/contrib/ui/filter.py +105 -43
- accrete/contrib/ui/static/css/accrete.css +128 -128
- accrete/contrib/ui/static/css/accrete.css.map +1 -1
- accrete/contrib/ui/static/css/accrete.scss +9 -9
- accrete/contrib/ui/static/js/filter.js +23 -0
- accrete/contrib/ui/static/js/htmx.min.js +1 -0
- accrete/contrib/ui/templates/ui/layout.html +133 -131
- accrete/contrib/ui/templates/ui/list.html +2 -2
- accrete/contrib/ui/templates/ui/partials/filter.html +28 -4
- accrete/contrib/ui/templates/ui/partials/header.html +5 -5
- accrete/contrib/ui/templates/ui/table.html +1 -1
- accrete/contrib/ui/templatetags/accrete_ui.py +10 -6
- accrete/contrib/user/templates/user/login.html +6 -12
- accrete/contrib/user/views.py +10 -9
- accrete/middleware.py +15 -0
- accrete/models.py +9 -7
- accrete/querystring.py +16 -25
- accrete/utils/models.py +19 -0
- {accrete-0.0.36.dist-info → accrete-0.0.38.dist-info}/METADATA +1 -1
- {accrete-0.0.36.dist-info → accrete-0.0.38.dist-info}/RECORD +25 -23
- accrete/contrib/ui/querystring.py +0 -19
- {accrete-0.0.36.dist-info → accrete-0.0.38.dist-info}/WHEEL +0 -0
- {accrete-0.0.36.dist-info → accrete-0.0.38.dist-info}/licenses/LICENSE +0 -0
accrete/annotation.py
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
from django.db.models import Field, QuerySet
|
2
|
+
from django.db.models.expressions import Func
|
3
|
+
from django.db.models.aggregates import Aggregate
|
4
|
+
|
5
|
+
|
6
|
+
class Annotation:
|
7
|
+
|
8
|
+
def __init__(
|
9
|
+
self,
|
10
|
+
verbose_name: str,
|
11
|
+
field: type[Field],
|
12
|
+
function: type[Func] | type[Aggregate],
|
13
|
+
help_text: str = '',
|
14
|
+
**kwargs
|
15
|
+
):
|
16
|
+
self.verbose_name = verbose_name or self
|
17
|
+
self.field = field
|
18
|
+
self.function = function
|
19
|
+
self.help_text = help_text
|
20
|
+
self.__dict__.update(kwargs)
|
21
|
+
|
22
|
+
|
23
|
+
class AnnotationModelMixin:
|
24
|
+
|
25
|
+
@classmethod
|
26
|
+
def get_annotations(cls) -> list[dict]:
|
27
|
+
return list({'name': a, 'annotation': getattr(cls, a)} for a in filter(
|
28
|
+
lambda a:
|
29
|
+
not a.startswith('__')
|
30
|
+
and isinstance(getattr(cls, a), Annotation),
|
31
|
+
cls.__dict__
|
32
|
+
))
|
33
|
+
|
34
|
+
|
35
|
+
class AnnotationManagerMixin:
|
36
|
+
|
37
|
+
@staticmethod
|
38
|
+
def add_annotations(queryset: QuerySet):
|
39
|
+
model = queryset.model
|
40
|
+
if not hasattr(model, 'get_annotations'):
|
41
|
+
return queryset
|
42
|
+
return queryset.annotate(**{
|
43
|
+
annotation['name']: annotation['annotation'].function
|
44
|
+
for annotation in model.get_annotations()
|
45
|
+
})
|
46
|
+
|
accrete/contrib/ui/__init__.py
CHANGED
@@ -4,18 +4,15 @@ from .context import (
|
|
4
4
|
TableContext,
|
5
5
|
ListContext,
|
6
6
|
FormContext,
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
DetailContent,
|
11
|
-
FormContent,
|
12
|
-
get_list_page,
|
13
|
-
get_detail_page,
|
7
|
+
form_actions,
|
8
|
+
list_page,
|
9
|
+
detail_page,
|
14
10
|
cast_param,
|
15
11
|
prepare_url_params,
|
16
12
|
extract_url_params,
|
17
13
|
exclude_params,
|
18
|
-
url_param_str
|
14
|
+
url_param_str,
|
15
|
+
get_table_fields
|
19
16
|
)
|
20
17
|
from .elements import (
|
21
18
|
ClientAction,
|
@@ -26,7 +23,3 @@ from .elements import (
|
|
26
23
|
TableFieldType,
|
27
24
|
Icon
|
28
25
|
)
|
29
|
-
from .querystring import (
|
30
|
-
load_querystring,
|
31
|
-
build_querystring,
|
32
|
-
)
|
accrete/contrib/ui/context.py
CHANGED
@@ -1,15 +1,20 @@
|
|
1
1
|
import logging
|
2
|
-
from typing import TypedDict
|
3
2
|
from dataclasses import dataclass, field
|
4
|
-
from
|
5
|
-
from django.
|
3
|
+
from typing import TypedDict
|
4
|
+
from django.utils.translation import gettext_lazy as _t
|
5
|
+
from django.db import models
|
6
6
|
from django.db.models import Model, QuerySet
|
7
7
|
from django.core.paginator import Paginator
|
8
8
|
from django.core import paginator
|
9
9
|
from django.forms import Form, ModelForm
|
10
|
-
from .
|
10
|
+
from accrete.utils.models import get_related_model
|
11
|
+
from accrete.annotation import Annotation
|
12
|
+
from .elements import (
|
13
|
+
ClientAction, BreadCrumb, TableField, TableFieldType, TableFieldAlignment
|
14
|
+
)
|
11
15
|
from .filter import Filter
|
12
16
|
|
17
|
+
|
13
18
|
_logger = logging.getLogger(__name__)
|
14
19
|
|
15
20
|
DEFAULT_PAGINATE_BY = 40
|
@@ -22,31 +27,55 @@ class DetailPagination(TypedDict):
|
|
22
27
|
total_objects: int
|
23
28
|
|
24
29
|
|
25
|
-
|
30
|
+
@dataclass(kw_only=True)
|
31
|
+
class Context:
|
32
|
+
|
33
|
+
title: str = ''
|
34
|
+
breadcrumbs: list[BreadCrumb] = field(default_factory=list)
|
35
|
+
actions: list[ClientAction] = field(default_factory=list)
|
36
|
+
kwargs: dict = field(default_factory=dict)
|
37
|
+
|
38
|
+
def __post_init__(self):
|
39
|
+
for key, value in self.kwargs.items():
|
40
|
+
setattr(self, key, value)
|
41
|
+
|
42
|
+
def dict(self):
|
43
|
+
return {
|
44
|
+
attr: getattr(self, attr, '') for attr
|
45
|
+
in filter(lambda x: not x.startswith('_'), self.__dict__)
|
46
|
+
}
|
47
|
+
|
48
|
+
|
49
|
+
@dataclass
|
50
|
+
class TableContext(Context):
|
26
51
|
|
27
|
-
title: str
|
28
52
|
object_label: str
|
29
|
-
object_param_str: str
|
30
53
|
fields: list[TableField]
|
31
|
-
breadcrumbs: list[BreadCrumb]
|
32
|
-
actions: list[ClientAction]
|
33
54
|
list_page: paginator.Page
|
34
55
|
pagination_param_str: str
|
35
56
|
endless_scroll: bool
|
36
57
|
filter: Filter
|
58
|
+
object_param_str: str = field(default='', kw_only=True)
|
37
59
|
|
38
60
|
|
39
|
-
|
61
|
+
@dataclass
|
62
|
+
class ListContext(Context):
|
40
63
|
|
41
|
-
title: str
|
42
64
|
object_label: str
|
43
65
|
object_param_str: str
|
44
|
-
breadcrumbs: list[BreadCrumb]
|
45
|
-
actions: list[ClientAction]
|
46
66
|
list_page: paginator.Page
|
47
67
|
pagination_param_str: str
|
48
|
-
endless_scroll: bool
|
49
68
|
filter: Filter
|
69
|
+
endless_scroll: bool = True
|
70
|
+
column_height: int = 4
|
71
|
+
column_width: int = 12
|
72
|
+
|
73
|
+
def __post_init__(self):
|
74
|
+
super().__post_init__()
|
75
|
+
if self.column_width not in range(1, 13):
|
76
|
+
_logger.warning(
|
77
|
+
'ListContext parameter column_width should be in range 1 - 12'
|
78
|
+
)
|
50
79
|
|
51
80
|
|
52
81
|
class DetailContext(TypedDict, total=False):
|
@@ -59,7 +88,7 @@ class DetailContext(TypedDict, total=False):
|
|
59
88
|
actions: list[ClientAction]
|
60
89
|
|
61
90
|
|
62
|
-
class FormContext(TypedDict):
|
91
|
+
class FormContext(TypedDict, total=False):
|
63
92
|
|
64
93
|
title: str
|
65
94
|
breadcrumbs: list[BreadCrumb]
|
@@ -68,204 +97,9 @@ class FormContext(TypedDict):
|
|
68
97
|
actions: list[ClientAction]
|
69
98
|
|
70
99
|
|
71
|
-
# @dataclass
|
72
|
-
# class ListContext:
|
73
|
-
#
|
74
|
-
# title: str
|
75
|
-
# actions: list[ClientAction] = field(default_factory=list)
|
76
|
-
# object_label: str = ''
|
77
|
-
# fields: list[TableField] = field(default_factory=list)
|
78
|
-
# page: paginator.Page = None
|
79
|
-
# list_pagination: bool = True
|
80
|
-
# endless_scroll: bool = True
|
81
|
-
# params: dict = field(default_factory=dict)
|
82
|
-
# filter: Filter = None
|
83
|
-
#
|
84
|
-
# def dict(self):
|
85
|
-
# return {
|
86
|
-
# "title": self.title,
|
87
|
-
# "actions": self.actions,
|
88
|
-
# "object_label": self.object_label,
|
89
|
-
# "fields": self.fields,
|
90
|
-
# "page": self.page,
|
91
|
-
# "list_pagination": self.list_pagination,
|
92
|
-
# "endless_scroll": self.endless_scroll,
|
93
|
-
# "params": self.params,
|
94
|
-
# "filter": self.filter
|
95
|
-
# }
|
96
|
-
|
97
|
-
|
98
|
-
class GenericContent:
|
99
|
-
|
100
|
-
def __init__(self, *args, **kwargs):
|
101
|
-
self.page: Page | None = None
|
102
|
-
|
103
|
-
def dict(self):
|
104
|
-
return {}
|
105
|
-
|
106
|
-
|
107
|
-
class ListContent:
|
108
|
-
|
109
|
-
def __init__(
|
110
|
-
self, queryset: QuerySet, paginate_by: int = DEFAULT_PAGINATE_BY,
|
111
|
-
page_number: int = 1, filter_obj: Filter = None,
|
112
|
-
endless_scroll: bool = True, fields: list[TableField] = None,
|
113
|
-
object_label: str = None, object_url_params: dict = None
|
114
|
-
):
|
115
|
-
self.queryset = queryset
|
116
|
-
self.paginate_by = paginate_by or DEFAULT_PAGINATE_BY
|
117
|
-
self.page_number = page_number
|
118
|
-
self.filter = filter_obj
|
119
|
-
self.endless_scroll = endless_scroll
|
120
|
-
self.fields = fields or []
|
121
|
-
self.object_label = object_label or _('Name')
|
122
|
-
self.object_url_params = object_url_params or {}
|
123
|
-
self.page: Page | None = None
|
124
|
-
|
125
|
-
def get_page_number(self, paginator):
|
126
|
-
if self.page_number < 1:
|
127
|
-
return 1
|
128
|
-
if self.page_number > paginator.num_pages:
|
129
|
-
return paginator.num_pages
|
130
|
-
return self.page_number
|
131
|
-
|
132
|
-
def dict(self):
|
133
|
-
paginator = Paginator(self.queryset, self.paginate_by)
|
134
|
-
page = paginator.page(self.get_page_number(paginator))
|
135
|
-
context = {
|
136
|
-
'page': page,
|
137
|
-
'object_label': self.object_label,
|
138
|
-
'object_url_params': url_param_str(prepare_url_params(self.object_url_params)),
|
139
|
-
'fields': self.fields,
|
140
|
-
'list_pagination': True if self.paginate_by > 0 else False,
|
141
|
-
'filter': self.filter,
|
142
|
-
'endless_scroll': self.endless_scroll
|
143
|
-
}
|
144
|
-
return context
|
145
|
-
|
146
|
-
|
147
|
-
class DetailContent:
|
148
|
-
|
149
|
-
def __init__(
|
150
|
-
self, obj: Model | type[Model], queryset: QuerySet = None,
|
151
|
-
):
|
152
|
-
self.obj = obj
|
153
|
-
self.queryset = queryset
|
154
|
-
self.page: Page | None = None
|
155
|
-
|
156
|
-
def get_detail_pagination(self):
|
157
|
-
return detail_pagination(self.queryset, self.obj)
|
158
|
-
|
159
|
-
def dict(self):
|
160
|
-
ctx = {
|
161
|
-
'object': self.obj,
|
162
|
-
'detail_pagination': False,
|
163
|
-
}
|
164
|
-
if self.queryset:
|
165
|
-
ctx.update(self.get_detail_pagination())
|
166
|
-
return ctx
|
167
|
-
|
168
|
-
|
169
|
-
class FormContent:
|
170
|
-
|
171
|
-
def __init__(
|
172
|
-
self, model: Model | type[Model], form: Form | ModelForm,
|
173
|
-
form_id: str = 'form', add_default_actions: bool = True,
|
174
|
-
discard_url: str = None
|
175
|
-
):
|
176
|
-
self.model = model
|
177
|
-
self.form = form
|
178
|
-
self.form_id = form_id
|
179
|
-
self.add_default_actions = add_default_actions
|
180
|
-
self.discard_url = discard_url
|
181
|
-
self.page: Page | None = None
|
182
|
-
|
183
|
-
def add_default_form_actions(self):
|
184
|
-
actions = [
|
185
|
-
ClientAction(
|
186
|
-
name=_('Save'),
|
187
|
-
submit=True,
|
188
|
-
class_list=['is-success'],
|
189
|
-
form_id=self.form_id
|
190
|
-
)
|
191
|
-
]
|
192
|
-
try:
|
193
|
-
url = self.discard_url or (self.model.pk and self.model.get_absolute_url())
|
194
|
-
except TypeError:
|
195
|
-
raise TypeError(
|
196
|
-
'Supply the discard_url parameter if Form is called '
|
197
|
-
'with a model class instead of an instance.'
|
198
|
-
)
|
199
|
-
except AttributeError as e:
|
200
|
-
_logger.error(
|
201
|
-
'Supply the discard_url parameter if Form is '
|
202
|
-
'called with a model instance that has the get_absolute_url '
|
203
|
-
'method not defined.'
|
204
|
-
)
|
205
|
-
raise e
|
206
|
-
|
207
|
-
actions.append(
|
208
|
-
ClientAction(
|
209
|
-
name=_('Discard'),
|
210
|
-
url=url,
|
211
|
-
)
|
212
|
-
)
|
213
|
-
if self.page:
|
214
|
-
self.page.actions = actions.extend(self.page.actions)
|
215
|
-
|
216
|
-
def get_title(self):
|
217
|
-
try:
|
218
|
-
int(self.model.pk)
|
219
|
-
return _('Edit')
|
220
|
-
except TypeError:
|
221
|
-
return _('Add')
|
222
|
-
except Exception as e:
|
223
|
-
_logger.exception(e)
|
224
|
-
return ''
|
225
|
-
|
226
|
-
def dict(self):
|
227
|
-
ctx = {
|
228
|
-
'form': self.form,
|
229
|
-
'form_id': self.form_id,
|
230
|
-
}
|
231
|
-
if self.add_default_actions:
|
232
|
-
self.add_default_form_actions()
|
233
|
-
if self.page and not self.page.title:
|
234
|
-
self.page.title = self.get_title()
|
235
|
-
return ctx
|
236
|
-
|
237
|
-
|
238
|
-
class Page:
|
239
|
-
|
240
|
-
def __init__(
|
241
|
-
self, *, title: str = None,
|
242
|
-
content: GenericContent | ListContent | DetailContent | FormContent = None,
|
243
|
-
breadcrumbs: list[BreadCrumb] = None, get_params: dict = None,
|
244
|
-
actions: list[ClientAction] = None,
|
245
|
-
):
|
246
|
-
self.title = title or ''
|
247
|
-
self.content = content
|
248
|
-
self.breadcrumbs = breadcrumbs or []
|
249
|
-
self.actions = actions or []
|
250
|
-
self.get_params = get_params or {}
|
251
|
-
if self.content:
|
252
|
-
self.content.page = self
|
253
|
-
|
254
|
-
def dict(self):
|
255
|
-
url_params = prepare_url_params(self.get_params)
|
256
|
-
ctx = {
|
257
|
-
'title': self.title,
|
258
|
-
'breadcrumbs': self.breadcrumbs,
|
259
|
-
'actions': self.actions,
|
260
|
-
'url_params': url_params,
|
261
|
-
'url_params_str': url_param_str(url_params)
|
262
|
-
}
|
263
|
-
if self.content:
|
264
|
-
ctx.update(self.content.dict())
|
265
|
-
return ctx
|
266
|
-
|
267
|
-
|
268
100
|
def cast_param(params: dict, param: str, cast_to: callable, default):
|
101
|
+
if param not in params:
|
102
|
+
return default
|
269
103
|
try:
|
270
104
|
return cast_to(params.get(param, default))
|
271
105
|
except Exception as e:
|
@@ -281,7 +115,7 @@ def url_param_str(params: dict, extract: list[str] = None) -> str:
|
|
281
115
|
"""
|
282
116
|
Return a URL Querystring from the given parameters
|
283
117
|
If extract is supplied, extract the value from the dictionary and prepare
|
284
|
-
them, so that each value is formatted
|
118
|
+
them, so that each value is formatted e.g. {'page': '&page=1'}
|
285
119
|
"""
|
286
120
|
if extract:
|
287
121
|
params = prepare_url_params(extract_url_params(params, extract))
|
@@ -302,12 +136,12 @@ def exclude_params(params: dict, keys: list[str]) -> dict:
|
|
302
136
|
return {key: val for key, val in params.items() if key not in keys}
|
303
137
|
|
304
138
|
|
305
|
-
def
|
139
|
+
def list_page(queryset: QuerySet, paginate_by: int, page_number: int) -> paginator.Page:
|
306
140
|
pages = Paginator(queryset, per_page=paginate_by)
|
307
141
|
return pages.page(page_number <= pages.num_pages and page_number or pages.num_pages)
|
308
142
|
|
309
143
|
|
310
|
-
def
|
144
|
+
def detail_page(queryset: QuerySet, obj: Model) -> dict:
|
311
145
|
if not hasattr(obj, 'get_absolute_url'):
|
312
146
|
_logger.warning(
|
313
147
|
'Detail pagination disabled for models without the '
|
@@ -330,16 +164,75 @@ def get_detail_page(queryset: QuerySet, obj: Model) -> dict:
|
|
330
164
|
}
|
331
165
|
|
332
166
|
|
333
|
-
def
|
167
|
+
def form_actions(discard_url: str, form_id: str = 'form') -> list[ClientAction]:
|
334
168
|
return [
|
335
169
|
ClientAction(
|
336
|
-
name=
|
170
|
+
name=_t('Save'),
|
337
171
|
submit=True,
|
338
172
|
class_list=['is-success'],
|
339
173
|
form_id=form_id,
|
340
174
|
),
|
341
175
|
ClientAction(
|
342
|
-
name=
|
176
|
+
name=_t('Discard'),
|
343
177
|
url=discard_url
|
344
178
|
)
|
345
179
|
]
|
180
|
+
|
181
|
+
|
182
|
+
def get_table_fields(
|
183
|
+
fields: list[str],
|
184
|
+
model: type[Model],
|
185
|
+
field_definition: dict[str | TableField] = None
|
186
|
+
) -> list[TableField]:
|
187
|
+
|
188
|
+
def get_alignment(field_attr):
|
189
|
+
number_field_types = (
|
190
|
+
models.DecimalField, models.IntegerField, models.FloatField
|
191
|
+
)
|
192
|
+
if (
|
193
|
+
isinstance(field_attr, number_field_types)
|
194
|
+
or (
|
195
|
+
isinstance(field_attr, Annotation)
|
196
|
+
and field_attr.field in number_field_types
|
197
|
+
)):
|
198
|
+
return TableFieldAlignment.RIGHT
|
199
|
+
return TableFieldAlignment.LEFT
|
200
|
+
|
201
|
+
|
202
|
+
def get_field_definition(f_name: str) -> TableField:
|
203
|
+
if definition := field_definition.get(f_name):
|
204
|
+
return definition
|
205
|
+
parts = f_name.split('__')
|
206
|
+
if len(parts) == 1:
|
207
|
+
rel_model = model
|
208
|
+
names = [rel_model._meta.verbose_name]
|
209
|
+
else:
|
210
|
+
rel_model, names = get_related_model(model, f_name)
|
211
|
+
try:
|
212
|
+
return rel_model.table_field_definition[parts[-1]]
|
213
|
+
except (AttributeError, KeyError):
|
214
|
+
attr = getattr(rel_model, parts[-1])
|
215
|
+
if isinstance(attr, Annotation):
|
216
|
+
pass
|
217
|
+
elif hasattr(attr, 'field'):
|
218
|
+
attr = attr.field
|
219
|
+
names.append(attr.verbose_name)
|
220
|
+
return TableField(
|
221
|
+
label=' / '.join([str(name) for name in names[1:]]),
|
222
|
+
name=f_name,
|
223
|
+
header_info=str(attr.help_text),
|
224
|
+
truncate_after=50,
|
225
|
+
alignment=get_alignment(attr)
|
226
|
+
)
|
227
|
+
|
228
|
+
if field_definition is None:
|
229
|
+
field_definition = {}
|
230
|
+
|
231
|
+
table_fields = []
|
232
|
+
for field_name in fields:
|
233
|
+
try:
|
234
|
+
table_fields.append(get_field_definition(field_name))
|
235
|
+
except AttributeError as e:
|
236
|
+
print(e)
|
237
|
+
pass
|
238
|
+
return table_fields
|