accrete 0.0.35__py3-none-any.whl → 0.0.37__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.
Files changed (31) hide show
  1. accrete/annotation.py +46 -0
  2. accrete/contrib/ui/__init__.py +12 -3
  3. accrete/contrib/ui/context.py +184 -257
  4. accrete/contrib/ui/elements.py +78 -2
  5. accrete/contrib/ui/filter.py +110 -44
  6. accrete/contrib/ui/static/css/accrete.css +128 -128
  7. accrete/contrib/ui/static/css/accrete.css.map +1 -1
  8. accrete/contrib/ui/static/css/accrete.scss +9 -9
  9. accrete/contrib/ui/static/js/filter.js +24 -0
  10. accrete/contrib/ui/static/js/htmx.min.js +1 -0
  11. accrete/contrib/ui/templates/ui/layout.html +134 -129
  12. accrete/contrib/ui/templates/ui/list.html +3 -3
  13. accrete/contrib/ui/templates/ui/partials/filter.html +29 -5
  14. accrete/contrib/ui/templates/ui/partials/header.html +7 -7
  15. accrete/contrib/ui/templates/ui/partials/pagination_detail.html +3 -3
  16. accrete/contrib/ui/templates/ui/partials/pagination_list.html +4 -4
  17. accrete/contrib/ui/templates/ui/table.html +18 -13
  18. accrete/contrib/ui/templatetags/accrete_ui.py +12 -1
  19. accrete/contrib/user/forms.py +0 -4
  20. accrete/contrib/user/templates/user/login.html +6 -12
  21. accrete/contrib/user/views.py +31 -21
  22. accrete/middleware.py +15 -0
  23. accrete/models.py +9 -7
  24. accrete/querystring.py +11 -8
  25. accrete/utils/models.py +14 -0
  26. {accrete-0.0.35.dist-info → accrete-0.0.37.dist-info}/METADATA +1 -1
  27. {accrete-0.0.35.dist-info → accrete-0.0.37.dist-info}/RECORD +29 -28
  28. accrete/contrib/ui/components.py +0 -96
  29. accrete/contrib/ui/querystring.py +0 -19
  30. {accrete-0.0.35.dist-info → accrete-0.0.37.dist-info}/WHEEL +0 -0
  31. {accrete-0.0.35.dist-info → accrete-0.0.37.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
+
@@ -1,10 +1,20 @@
1
1
  from .filter import Filter
2
2
  from .context import (
3
- ListContext,
4
3
  DetailContext,
4
+ TableContext,
5
+ ListContext,
5
6
  FormContext,
7
+ form_actions,
8
+ list_page,
9
+ detail_page,
10
+ cast_param,
11
+ prepare_url_params,
12
+ extract_url_params,
13
+ exclude_params,
14
+ url_param_str,
15
+ get_table_fields
6
16
  )
7
- from .components import (
17
+ from .elements import (
8
18
  ClientAction,
9
19
  ActionMethod,
10
20
  BreadCrumb,
@@ -13,4 +23,3 @@ from .components import (
13
23
  TableFieldType,
14
24
  Icon
15
25
  )
16
- from .querystring import load_querystring, build_querystring
@@ -1,291 +1,218 @@
1
1
  import logging
2
2
  from dataclasses import dataclass, field
3
-
4
- from django.utils.translation import gettext_lazy as _
5
- from django.db.models import Model, QuerySet, Q
3
+ from typing import TypedDict
4
+ from django.utils.translation import gettext_lazy as _t
5
+ from django.db.models import Model, QuerySet
6
6
  from django.core.paginator import Paginator
7
+ from django.core import paginator
7
8
  from django.forms import Form, ModelForm
8
- from accrete.querystring import parse_querystring
9
-
10
- from .querystring import load_querystring, build_querystring
11
- from .components import ClientAction, BreadCrumb, TableField
9
+ from accrete.utils.models import get_related_model
10
+ from .elements import ClientAction, BreadCrumb, TableField, TableFieldType
12
11
  from .filter import Filter
12
+ from ...annotation import Annotation
13
13
 
14
14
  _logger = logging.getLogger(__name__)
15
15
 
16
16
  DEFAULT_PAGINATE_BY = 40
17
17
 
18
18
 
19
- @dataclass
20
- class ListContext:
21
-
22
- model: type[Model]
23
- get_params: dict
24
- title: str = None
25
- context: dict = field(default_factory=dict)
26
- queryset: QuerySet = None
27
- select_related: list[str] = field(default_factory=list)
28
- prefetch_related: list[str] = field(default_factory=list)
29
- annotate: dict = field(default_factory=dict)
30
- paginate_by: int = DEFAULT_PAGINATE_BY
31
- order_by: list[str] = field(default_factory=list)
32
- filter_relation_depth: int = 4
33
- default_filter_term: str = ''
34
- actions: list[ClientAction] = field(default_factory=list)
35
- breadcrumbs: list[BreadCrumb] = field(default_factory=list)
36
- object_label: str = None
37
- fields: list[TableField] = field(default_factory=list)
38
- unselect_button: bool = False
39
- endless_scroll: bool = True
19
+ class DetailPagination(TypedDict):
20
+ previous_object_url: str
21
+ next_object_url: str
22
+ current_object_idx: int
23
+ total_objects: int
40
24
 
41
- def get_queryset(self):
42
- if self.queryset:
43
- return self.queryset
44
-
45
- order = self.order_by or self.model._meta.ordering
46
-
47
- # pks = self.model.objects.filter(
48
- # parse_querystring(self.model, self.get_params.get('q', '[]'))
49
- # ).distinct().values_list('pk', flat=True)
50
-
51
- queryset = self.model.objects.select_related(
52
- *self.select_related
53
- ).prefetch_related(
54
- *self.prefetch_related
55
- ).filter(
56
- parse_querystring(self.model, self.get_params.get('q', '[]'))
57
- ).annotate(
58
- **self.get_annotations()
59
- ).order_by(
60
- *order
61
- ).distinct()
62
-
63
- return queryset
64
-
65
- def get_annotations(self):
66
- annotations = {
67
- annotation['name']: annotation['func']
68
- for annotation in getattr(self.model, 'annotations', [])
69
- }
70
- if self.annotate:
71
- annotations.update(self.annotate)
72
- return annotations
73
25
 
74
- def get_page_number(self, paginator):
75
- page_number = self.get_params.get('page', '1')
26
+ @dataclass(kw_only=True)
27
+ class Context:
76
28
 
77
- try:
78
- page_number = int(page_number)
79
- except ValueError:
80
- page_number = 1
81
-
82
- if page_number < 1:
83
- page_number = 1
84
- elif page_number > paginator.num_pages:
85
- page_number = paginator.num_pages
86
- return page_number
87
-
88
- def get_paginate_by(self):
89
- paginate_by = self.get_params.get('paginate_by', self.paginate_by)
90
- try:
91
- paginate_by = int(paginate_by)
92
- except ValueError:
93
- paginate_by = self.paginate_by
94
- return paginate_by
29
+ title: str = ''
30
+ breadcrumbs: list[BreadCrumb] = field(default_factory=list)
31
+ actions: list[ClientAction] = field(default_factory=list)
32
+ kwargs: dict = field(default_factory=dict)
33
+
34
+ def __post_init__(self):
35
+ for key, value in self.kwargs.items():
36
+ setattr(self, key, value)
95
37
 
96
38
  def dict(self):
97
- queryset = self.get_queryset()
98
- paginate_by = self.get_paginate_by()
99
- paginator = Paginator(queryset, paginate_by)
100
- page = paginator.page(self.get_page_number(paginator))
101
- context = {
102
- 'queryset': queryset,
103
- 'paginate_by': paginate_by,
104
- 'order_by': self.get_params.get('order_by', self.model._meta.ordering),
105
- 'paginator': paginator,
106
- 'page': page,
107
- 'list_pagination': True,
108
- 'title': self.title or self.model._meta.verbose_name_plural,
109
- 'object_label': self.object_label or self.model._meta.verbose_name or _('Name'),
110
- 'filter': Filter(self.model, self.filter_relation_depth),
111
- 'default_filter_term': self.default_filter_term,
112
- 'breadcrumbs': self.breadcrumbs,
113
- 'querystring': load_querystring(self.get_params),
114
- 'url_params': build_querystring(self.get_params),
115
- 'actions': self.actions,
116
- 'fields': self.fields,
117
- 'endless_scroll': self.endless_scroll
39
+ return {
40
+ attr: getattr(self, attr, '') for attr
41
+ in filter(lambda x: not x.startswith('_'), self.__dict__)
118
42
  }
119
- context.update(self.context)
120
- return context
121
43
 
122
44
 
123
45
  @dataclass
124
- class DetailContext:
125
-
126
- obj: Model | type[Model]
127
- get_params: dict
128
- order_by: str = None
129
- paginate_by: int = DEFAULT_PAGINATE_BY
130
- title: str = None
131
- queryset: type[QuerySet] = None
132
- select_related: list[str] = field(default_factory=list)
133
- prefetch_related: list[str] = field(default_factory=list)
134
- annotate: dict = field(default_factory=dict)
135
- actions: list[ClientAction] = field(default_factory=list)
136
- breadcrumbs: list[BreadCrumb] = field(default_factory=list)
137
- context: dict = field(default_factory=dict)
138
-
139
- def get_queryset(self):
140
- if self.queryset:
141
- return self.queryset
142
-
143
- order = self.order_by or self.obj._meta.model._meta.ordering
144
-
145
- pks = self.obj._meta.model.objects.filter(
146
- parse_querystring(self.obj._meta.model, self.get_params.get('q', '[]'))
147
- ).distinct().values_list('pk', flat=True)
148
-
149
- queryset = self.obj._meta.model.objects.select_related(
150
- *self.select_related
151
- ).prefetch_related(
152
- *self.prefetch_related
153
- ).filter(
154
- Q(pk__in=pks)
155
- ).annotate(
156
- **self.get_annotations()
157
- ).order_by(
158
- *order
159
- ).distinct()
160
-
161
- return queryset
162
-
163
- def get_annotations(self):
164
- annotations = {
165
- annotation['name']: annotation['func']
166
- for annotation in getattr(self.obj._meta.model, 'annotations', [])
167
- }
168
- if self.annotate:
169
- annotations.update(self.annotate)
170
- return annotations
46
+ class TableContext(Context):
171
47
 
172
- def get_paginate_by(self):
173
- paginate_by = self.get_params.get('paginate_by', self.paginate_by)
174
- try:
175
- paginate_by = int(paginate_by)
176
- except ValueError:
177
- paginate_by = self.paginate_by
178
- return paginate_by
48
+ object_label: str
49
+ fields: list[TableField]
50
+ list_page: paginator.Page
51
+ pagination_param_str: str
52
+ endless_scroll: bool
53
+ filter: Filter
54
+ object_param_str: str = field(default='', kw_only=True)
55
+
56
+
57
+ @dataclass
58
+ class ListContext(Context):
59
+
60
+ object_label: str
61
+ object_param_str: str
62
+ list_page: paginator.Page
63
+ pagination_param_str: str
64
+ filter: Filter
65
+ endless_scroll: bool = True
66
+ column_height: int = 4
67
+ column_width: int = 12
179
68
 
180
- def get_pagination_context(self):
181
- if not hasattr(self.obj, 'get_absolute_url'):
69
+ def __post_init__(self):
70
+ super().__post_init__()
71
+ if self.column_width not in range(1, 13):
182
72
  _logger.warning(
183
- 'Detail pagination disabled for models without the '
184
- 'get_absolute_url attribute. Set paginate_by to 0 to '
185
- 'deactivate pagination.'
73
+ 'ListContext parameter column_width should be in range 1 - 12'
186
74
  )
187
- return {}
188
- queryset = self.get_queryset()
189
- idx = (*queryset,).index(self.obj)
190
- previous_object_url = (
191
- queryset[idx - 1] if idx - 1 >= 0 else queryset.last()
192
- ).get_absolute_url()
193
- next_object_url = (
194
- queryset[idx + 1] if idx + 1 <= queryset.count() - 1 else queryset.first()
195
- ).get_absolute_url()
196
- ctx = {
197
- 'previous_object_url': previous_object_url,
198
- 'next_object_url': next_object_url,
199
- 'current_object_idx': idx + 1,
200
- 'total_objects': queryset.count(),
201
- 'detail_pagination': True
202
- }
203
- return ctx
204
75
 
205
- def dict(self):
206
- paginate_by = self.get_paginate_by()
207
- ctx = {
208
- 'object': self.get_queryset().get(pk=self.obj.pk),
209
- 'title': self.title or self.obj,
210
- 'order_by': self.get_params.get('order_by', self.obj._meta.model._meta.ordering),
211
- 'paginate_by': paginate_by,
212
- 'detail_pagination': False,
213
- 'breadcrumbs': self.breadcrumbs,
214
- 'url_params': build_querystring(self.get_params, ['page']),
215
- 'actions': self.actions
216
- }
217
- if self.paginate_by > 0:
218
- ctx.update(self.get_pagination_context())
219
- ctx.update(self.context)
220
- return ctx
221
76
 
77
+ class DetailContext(TypedDict, total=False):
222
78
 
223
- @dataclass
224
- class FormContext:
79
+ title: str
80
+ object: Model
81
+ breadcrumbs: list[BreadCrumb]
82
+ detail_page: DetailPagination
83
+ pagination_param_str: str
84
+ actions: list[ClientAction]
85
+
86
+
87
+ class FormContext(TypedDict, total=False):
225
88
 
226
- model: Model | type[Model]
89
+ title: str
90
+ breadcrumbs: list[BreadCrumb]
227
91
  form: Form | ModelForm
228
- get_params: dict
229
- title: str = None
230
- context: dict = field(default_factory=dict)
231
- form_id: str = 'form'
232
- add_default_actions: bool = True
233
- discard_url: str = None
234
- actions: list[ClientAction] = field(default_factory=list)
235
- breadcrumbs: list[BreadCrumb] = field(default_factory=list)
92
+ form_id: str
93
+ actions: list[ClientAction]
236
94
 
237
- def get_default_form_actions(self):
238
- actions = [
239
- ClientAction(
240
- name=_('Save'),
241
- submit=True,
242
- class_list=['is-success'],
243
- form_id=self.form_id
244
- )
245
- ]
246
- try:
247
- url = self.discard_url or (self.model.pk and self.model.get_absolute_url())
248
- except TypeError:
249
- raise TypeError(
250
- 'Supply the discard_url parameter if FormContext is called '
251
- 'with a model class instead of an instance.'
252
- )
253
- except AttributeError as e:
254
- _logger.error(
255
- 'Supply the discard_url parameter if FormContext is '
256
- 'called with a model instance that has the get_absolute_url '
257
- 'method not defined.'
258
- )
259
- raise e
260
95
 
261
- actions.append(
262
- ClientAction(
263
- name=_('Discard'),
264
- url=url,
265
- )
266
- )
267
- return actions
96
+ def cast_param(params: dict, param: str, cast_to: callable, default):
97
+ if param not in params:
98
+ return default
99
+ try:
100
+ return cast_to(params.get(param, default))
101
+ except Exception as e:
102
+ _logger.exception(e)
103
+ return default
104
+
105
+
106
+ def prepare_url_params(get_params: dict) -> dict:
107
+ return {key: f'&{key}={value}' for key, value in get_params.items()}
108
+
109
+
110
+ def url_param_str(params: dict, extract: list[str] = None) -> str:
111
+ """
112
+ Return a URL Querystring from the given parameters
113
+ If extract is supplied, extract the value from the dictionary and prepare
114
+ them, so that each value is formatted e.g. {'page': '&page=1'}
115
+ """
116
+ if extract:
117
+ params = prepare_url_params(extract_url_params(params, extract))
118
+ param_str = (
119
+ "".join(str(value) for value in params.values())
120
+ .replace('&&', '&')
121
+ .replace('?&', '?')
122
+ .strip('?&')
123
+ )
124
+ return f'?{param_str}'
125
+
268
126
 
269
- def get_title(self):
270
- if self.title:
271
- return self.title
127
+ def extract_url_params(params: dict, keys: list[str]) -> dict:
128
+ return {key: params[key] for key in keys if key in params}
129
+
130
+
131
+ def exclude_params(params: dict, keys: list[str]) -> dict:
132
+ return {key: val for key, val in params.items() if key not in keys}
133
+
134
+
135
+ def list_page(queryset: QuerySet, paginate_by: int, page_number: int) -> paginator.Page:
136
+ pages = Paginator(queryset, per_page=paginate_by)
137
+ return pages.page(page_number <= pages.num_pages and page_number or pages.num_pages)
138
+
139
+
140
+ def detail_page(queryset: QuerySet, obj: Model) -> dict:
141
+ if not hasattr(obj, 'get_absolute_url'):
142
+ _logger.warning(
143
+ 'Detail pagination disabled for models without the '
144
+ 'get_absolute_url attribute. Set paginate_by to 0 to '
145
+ 'deactivate pagination.'
146
+ )
147
+ return {}
148
+ idx = (*queryset,).index(obj)
149
+ previous_object_url = (
150
+ queryset[idx - 1] if idx - 1 >= 0 else queryset.last()
151
+ ).get_absolute_url()
152
+ next_object_url = (
153
+ queryset[idx + 1] if idx + 1 <= queryset.count() - 1 else queryset.first()
154
+ ).get_absolute_url()
155
+ return {
156
+ 'previous_object_url': previous_object_url,
157
+ 'next_object_url': next_object_url,
158
+ 'current_object_idx': idx + 1,
159
+ 'total_objects': queryset.count()
160
+ }
161
+
162
+
163
+ def form_actions(discard_url: str, form_id: str = 'form') -> list[ClientAction]:
164
+ return [
165
+ ClientAction(
166
+ name=_t('Save'),
167
+ submit=True,
168
+ class_list=['is-success'],
169
+ form_id=form_id,
170
+ ),
171
+ ClientAction(
172
+ name=_t('Discard'),
173
+ url=discard_url
174
+ )
175
+ ]
176
+
177
+
178
+ def get_table_fields(
179
+ fields: list[str],
180
+ model: type[Model],
181
+ field_definition: dict[str | TableField] = None
182
+ ) -> list[TableField]:
183
+ print(fields, model, field_definition)
184
+
185
+ if field_definition is None:
186
+ field_definition = {}
187
+
188
+ def get_field_definition(f_name: str) -> TableField:
189
+ if definition := field_definition.get(f_name):
190
+ return definition
191
+ if len(f_name.split('__')) == 1:
192
+ rel_model = model
193
+ else:
194
+ rel_model = get_related_model(model, f_name)
195
+ print(rel_model)
272
196
  try:
273
- int(self.model.pk)
274
- return _('Edit')
275
- except TypeError:
276
- return _('Add')
197
+ return rel_model.table_field_definition[f_name]
198
+ except (AttributeError, KeyError):
199
+ attr = getattr(rel_model, f_name)
200
+ if isinstance(attr, Annotation):
201
+ attr = attr
202
+ elif hasattr(attr, 'field'):
203
+ attr = attr.field
204
+ return TableField(
205
+ label=str(attr.verbose_name),
206
+ name=f_name,
207
+ header_info=str(attr.help_text),
208
+ truncate_after=50
209
+ )
277
210
 
278
- def dict(self):
279
- ctx = {
280
- 'title': self.get_title(),
281
- 'form': self.form,
282
- 'form_id': self.form_id,
283
- 'url_params': build_querystring(self.get_params, ['page']),
284
- 'actions': [],
285
- 'breadcrumbs': self.breadcrumbs,
286
- }
287
- if self.add_default_actions:
288
- ctx.update({'actions': self.get_default_form_actions()})
289
- ctx['actions'].extend(self.actions)
290
- ctx.update(self.context)
291
- return ctx
211
+ table_fields = []
212
+ for field_name in fields:
213
+ try:
214
+ table_fields.append(get_field_definition(field_name))
215
+ except AttributeError as e:
216
+ print(e)
217
+ pass
218
+ return table_fields
@@ -1,2 +1,78 @@
1
- from dataclasses import dataclass
2
- from django.forms import Form, ModelForm
1
+ from enum import Enum
2
+ from dataclasses import dataclass, field
3
+
4
+
5
+ class Icon(Enum):
6
+
7
+ ADD = 'icon-add'
8
+ EDIT = 'icon-edit'
9
+ LIST = 'icon-list'
10
+ OPEN_RELATED = 'icon-open-related'
11
+ ENVELOPE = 'icon-envelope'
12
+ CLEAR = 'icon-clear'
13
+ BACKSPACE = 'icon-backspace'
14
+ FILTER = 'icon-filter'
15
+ DELETE_FILTER = 'icon-delete-filter'
16
+ SELECT = 'icon-select'
17
+
18
+
19
+ class ActionMethod(Enum):
20
+
21
+ HREF = 'href'
22
+ GET = 'hx-get'
23
+ POST = 'hx-post'
24
+ PUT = 'hx-put'
25
+ DELETE = 'hx-delete'
26
+
27
+
28
+ @dataclass
29
+ class ClientAction:
30
+
31
+ name: str
32
+ url: str = ''
33
+ method: ActionMethod = ActionMethod.HREF
34
+ attrs: list[tuple[str, str]] = field(default_factory=list)
35
+ submit: bool = False
36
+ form_id: str = 'form'
37
+ class_list: list[str] = field(default_factory=list)
38
+ icon: Icon | type[Enum] = None
39
+
40
+ def attrs_str(self):
41
+ return ' '.join([f'{str(attr[0])}={str(attr[1])}' for attr in self.attrs])
42
+
43
+
44
+ class TableFieldAlignment(Enum):
45
+
46
+ LEFT = 'left'
47
+ CENTER = 'center'
48
+ RIGHT = 'right'
49
+
50
+
51
+ class TableFieldType(Enum):
52
+
53
+ NONE = ''
54
+ STRING = '_string'
55
+ MONETARY = '_monetary'
56
+ FLOAT = '_float'
57
+
58
+
59
+ @dataclass
60
+ class TableField:
61
+
62
+ label: str
63
+ name: str
64
+ alignment: TableFieldAlignment | Enum = TableFieldAlignment.LEFT
65
+ header_alignment: TableFieldAlignment | Enum = None
66
+ header_info: str = None
67
+ field_type: TableFieldType | Enum = TableFieldType.NONE
68
+ prefix: str = ''
69
+ suffix: str = ''
70
+ truncate_after: int = 0
71
+ template: str = None
72
+
73
+
74
+ @dataclass
75
+ class BreadCrumb:
76
+
77
+ name: str
78
+ url: str