accrete 0.0.21__py3-none-any.whl → 0.0.23__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 (32) hide show
  1. accrete/contrib/sequence/forms.py +2 -2
  2. accrete/contrib/sequence/queries.py +3 -4
  3. accrete/contrib/ui/__init__.py +10 -5
  4. accrete/contrib/ui/components.py +49 -0
  5. accrete/contrib/ui/context.py +284 -0
  6. accrete/contrib/ui/filter.py +16 -17
  7. accrete/contrib/ui/querystring.py +93 -0
  8. accrete/contrib/ui/static/css/accrete.css +225 -162
  9. accrete/contrib/ui/static/css/accrete.css.map +1 -1
  10. accrete/contrib/ui/static/css/accrete.scss +90 -21
  11. accrete/contrib/ui/static/js/filter.js +18 -11
  12. accrete/contrib/ui/templates/ui/detail.html +1 -3
  13. accrete/contrib/ui/templates/ui/layout.html +3 -3
  14. accrete/contrib/ui/templates/ui/list.html +3 -3
  15. accrete/contrib/ui/templates/ui/partials/filter.html +1 -0
  16. accrete/contrib/ui/templates/ui/partials/header.html +1 -1
  17. accrete/contrib/ui/templates/ui/partials/pagination_detail.html +2 -2
  18. accrete/contrib/ui/templates/ui/partials/pagination_list.html +5 -5
  19. accrete/contrib/ui/templates/ui/partials/table_field.html +15 -0
  20. accrete/contrib/ui/templates/ui/partials/table_field_float.html +1 -0
  21. accrete/contrib/ui/templates/ui/partials/table_field_monetary.html +4 -0
  22. accrete/contrib/ui/templates/ui/partials/table_field_string.html +1 -0
  23. accrete/contrib/ui/templates/ui/table.html +13 -4
  24. accrete/contrib/user/templates/user/user_detail.html +9 -9
  25. accrete/contrib/user/templates/user/user_form.html +2 -0
  26. accrete/contrib/user/views.py +2 -2
  27. accrete/queries.py +5 -1
  28. {accrete-0.0.21.dist-info → accrete-0.0.23.dist-info}/METADATA +1 -1
  29. {accrete-0.0.21.dist-info → accrete-0.0.23.dist-info}/RECORD +31 -25
  30. accrete/contrib/ui/helper.py +0 -432
  31. {accrete-0.0.21.dist-info → accrete-0.0.23.dist-info}/WHEEL +0 -0
  32. {accrete-0.0.21.dist-info → accrete-0.0.23.dist-info}/licenses/LICENSE +0 -0
@@ -1,8 +1,8 @@
1
- from tenant.forms import ModelForm
1
+ from accrete.forms import TenantModelForm
2
2
  from .models import Sequence
3
3
 
4
4
 
5
- class SequenceCreateForm(ModelForm):
5
+ class SequenceCreateForm(TenantModelForm):
6
6
  class Meta:
7
7
  model = Sequence
8
8
  fields = [
@@ -1,13 +1,12 @@
1
- import logging
2
1
  from django.db import transaction
3
2
  from django.db.models import F
4
3
 
4
+ from accrete.tenant import get_tenant
5
5
  from .models import Sequence
6
6
 
7
- _logger = logging.getLogger(__name__)
8
7
 
9
-
10
- def get_nextval(tenant, name, create_if_none=True):
8
+ def get_nextval(name, create_if_none=True):
9
+ tenant = get_tenant()
11
10
  with transaction.atomic():
12
11
  seq = Sequence.objects.filter(
13
12
  tenant=tenant.id, name=name
@@ -1,12 +1,17 @@
1
1
  from .filter import Filter
2
- from .helper import (
3
- ClientAction,
2
+ from .context import (
3
+ ListContext,
4
4
  DetailContext,
5
5
  FormContext,
6
- ListContext,
6
+ )
7
+ from .components import (
8
+ ClientAction,
7
9
  BreadCrumb,
8
10
  TableField,
9
11
  TableFieldAlignment,
10
- build_querystring,
11
- queryset_from_querystring
12
+ TableFieldType
13
+ )
14
+ from .querystring import (
15
+ parse_querystring,
16
+ build_querystring
12
17
  )
@@ -0,0 +1,49 @@
1
+ from dataclasses import dataclass, field
2
+ from enum import Enum
3
+
4
+
5
+ class TableFieldAlignment(Enum):
6
+
7
+ LEFT = 'left'
8
+ CENTER = 'center'
9
+ RIGHT = 'right'
10
+
11
+
12
+ class TableFieldType(Enum):
13
+
14
+ NONE = ''
15
+ STRING = '_string'
16
+ MONETARY = '_monetary'
17
+ FLOAT = '_float'
18
+
19
+
20
+ @dataclass
21
+ class TableField:
22
+
23
+ label: str
24
+ name: str
25
+ alignment: type[TableFieldAlignment] = TableFieldAlignment.LEFT
26
+ header_alignment: type[TableFieldAlignment] = None
27
+ field_type: type[TableFieldType] = TableFieldType.NONE
28
+ prefix: str = ''
29
+ suffix: str = ''
30
+ truncate_after: int = 0
31
+
32
+
33
+ @dataclass
34
+ class BreadCrumb:
35
+
36
+ name: str
37
+ url: str
38
+
39
+
40
+ @dataclass
41
+ class ClientAction:
42
+
43
+ name: str
44
+ url: str = ''
45
+ query_params: str = ''
46
+ attrs: list[str] = field(default_factory=list)
47
+ submit: bool = False
48
+ form_id: str = 'form'
49
+ class_list: list = field(default_factory=list)
@@ -0,0 +1,284 @@
1
+ import logging
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
6
+ from django.core.paginator import Paginator
7
+
8
+ from .querystring import parse_querystring, build_querystring
9
+ from .components import ClientAction, BreadCrumb, TableField
10
+ from .filter import Filter
11
+
12
+ _logger = logging.getLogger(__name__)
13
+
14
+ DEFAULT_PAGINATE_BY = 40
15
+
16
+
17
+ @dataclass
18
+ class ListContext:
19
+
20
+ model: Model
21
+ get_params: dict
22
+ title: str = None
23
+ context: dict = field(default_factory=dict)
24
+ queryset: QuerySet = None
25
+ select_related: list[str] = field(default_factory=list)
26
+ prefetch_related: list[str] = field(default_factory=list)
27
+ annotate: dict = field(default_factory=dict)
28
+ paginate_by: int = DEFAULT_PAGINATE_BY
29
+ order_by: list[str] = field(default_factory=list)
30
+ filter_relation_depth: int = 4
31
+ default_filter_term: str = ''
32
+ actions: list[ClientAction] = field(default_factory=list)
33
+ breadcrumbs: list[BreadCrumb] = field(default_factory=list)
34
+ obj_label: str = None
35
+ fields: list[TableField] = field(default_factory=list)
36
+ unselect_button: bool = False
37
+
38
+ def get_queryset(self):
39
+ if self.queryset:
40
+ return self.queryset
41
+
42
+ order = self.order_by or self.model._meta.ordering
43
+
44
+ pks = self.model.objects.filter(
45
+ parse_querystring(self.model, self.get_params.get('q', '[]'))
46
+ ).distinct().values_list('pk', flat=True)
47
+
48
+ queryset = self.model.objects.select_related(
49
+ *self.select_related
50
+ ).prefetch_related(
51
+ *self.prefetch_related
52
+ ).filter(
53
+ Q(pk__in=pks)
54
+ ).annotate(
55
+ **self.get_annotations()
56
+ ).order_by(
57
+ *order
58
+ ).distinct()
59
+
60
+ return queryset
61
+
62
+ def get_annotations(self):
63
+ annotations = {
64
+ annotation['name']: annotation['func']
65
+ for annotation in getattr(self.model, 'annotations', [])
66
+ }
67
+ if self.annotate:
68
+ annotations.update(self.annotate)
69
+ return annotations
70
+
71
+ def get_page_number(self, paginator):
72
+ page_number = self.get_params.get('page', '1')
73
+
74
+ try:
75
+ page_number = int(page_number)
76
+ except ValueError:
77
+ page_number = 1
78
+
79
+ if page_number < 1:
80
+ page_number = 1
81
+ elif page_number > paginator.num_pages:
82
+ page_number = paginator.num_pages
83
+ return page_number
84
+
85
+ def get_paginate_by(self):
86
+ paginate_by = self.get_params.get('paginate_by', self.paginate_by)
87
+ try:
88
+ paginate_by = int(paginate_by)
89
+ except ValueError:
90
+ paginate_by = self.paginate_by
91
+ return paginate_by
92
+
93
+ def dict(self):
94
+ queryset = self.get_queryset()
95
+ paginate_by = self.get_paginate_by()
96
+ paginator = Paginator(queryset, paginate_by)
97
+ page = paginator.page(self.get_page_number(paginator))
98
+ context = {
99
+ 'queryset': queryset,
100
+ 'paginate_by': paginate_by,
101
+ 'order_by': self.get_params.get('order_by', self.model._meta.ordering),
102
+ 'paginator': paginator,
103
+ 'page': page,
104
+ 'list_pagination': True,
105
+ 'title': self.title or self.model._meta.verbose_name_plural,
106
+ 'filter_terms': Filter(self.model, self.filter_relation_depth).get_query_terms(),
107
+ 'default_filter_term': self.default_filter_term,
108
+ 'breadcrumbs': self.breadcrumbs,
109
+ 'querystring': build_querystring(self.get_params),
110
+ 'actions': self.actions,
111
+ 'obj_label': self.obj_label or _('Name'),
112
+ 'fields': self.fields
113
+ }
114
+ context.update(self.context)
115
+ return context
116
+
117
+
118
+ @dataclass
119
+ class DetailContext:
120
+
121
+ obj: Model
122
+ get_params: dict
123
+ order_by: str = None
124
+ paginate_by: int = DEFAULT_PAGINATE_BY
125
+ title: str = None
126
+ queryset: type[QuerySet] = None
127
+ select_related: list[str] = field(default_factory=list)
128
+ prefetch_related: list[str] = field(default_factory=list)
129
+ annotate: dict = field(default_factory=dict)
130
+ actions: list[ClientAction] = field(default_factory=list)
131
+ breadcrumbs: list[BreadCrumb] = field(default_factory=list)
132
+ context: dict = field(default_factory=dict)
133
+
134
+ def get_queryset(self):
135
+ if self.queryset:
136
+ return self.queryset
137
+
138
+ order = self.order_by or self.obj._meta.model._meta.ordering
139
+
140
+ pks = self.obj._meta.model.objects.filter(
141
+ parse_querystring(self.obj._meta.model, self.get_params.get('q', '[]'))
142
+ ).distinct().values_list('pk', flat=True)
143
+
144
+ queryset = self.obj._meta.model.objects.select_related(
145
+ *self.select_related
146
+ ).prefetch_related(
147
+ *self.prefetch_related
148
+ ).filter(
149
+ Q(pk__in=pks)
150
+ ).annotate(
151
+ **self.get_annotations()
152
+ ).order_by(
153
+ *order
154
+ ).distinct()
155
+
156
+ return queryset
157
+
158
+ def get_annotations(self):
159
+ annotations = {
160
+ annotation['name']: annotation['func']
161
+ for annotation in getattr(self.obj._meta.model, 'annotations', [])
162
+ }
163
+ if self.annotate:
164
+ annotations.update(self.annotate)
165
+ return annotations
166
+
167
+ def get_paginate_by(self):
168
+ paginate_by = self.get_params.get('paginate_by', self.paginate_by)
169
+ try:
170
+ paginate_by = int(paginate_by)
171
+ except ValueError:
172
+ paginate_by = self.paginate_by
173
+ return paginate_by
174
+
175
+ def get_pagination_context(self):
176
+ if not hasattr(self.obj, 'get_absolute_url'):
177
+ _logger.warning(
178
+ 'Detail pagination disabled for models without the '
179
+ 'get_absolute_url attribute. Set paginate_by to 0 to '
180
+ 'deactivate pagination.'
181
+ )
182
+ return {}
183
+ queryset = self.get_queryset()
184
+ idx = (*queryset,).index(self.obj)
185
+ previous_object_url = (
186
+ queryset[idx - 1] if idx - 1 >= 0 else queryset.last()
187
+ ).get_absolute_url()
188
+ next_object_url = (
189
+ queryset[idx + 1] if idx + 1 <= queryset.count() - 1 else queryset.first()
190
+ ).get_absolute_url()
191
+ ctx = {
192
+ 'previous_object_url': previous_object_url,
193
+ 'next_object_url': next_object_url,
194
+ 'current_object_idx': idx + 1,
195
+ 'total_objects': queryset.count(),
196
+ 'detail_pagination': True
197
+ }
198
+ return ctx
199
+
200
+ def dict(self):
201
+ paginate_by = self.get_paginate_by()
202
+ ctx = {
203
+ 'object': self.obj,
204
+ 'title': self.title or self.obj,
205
+ 'order_by': self.get_params.get('order_by', self.obj._meta.model._meta.ordering),
206
+ 'paginate_by': paginate_by,
207
+ 'detail_pagination': False,
208
+ 'view_type': 'detail',
209
+ 'breadcrumbs': self.breadcrumbs,
210
+ 'querystring': build_querystring(self.get_params, ['page']),
211
+ 'actions': self.actions
212
+ }
213
+ if self.paginate_by > 0:
214
+ ctx.update(self.get_pagination_context())
215
+ ctx.update(self.context)
216
+ return ctx
217
+
218
+
219
+ @dataclass
220
+ class FormContext:
221
+
222
+ model: Model | type[Model]
223
+ get_params: dict
224
+ title: str = None
225
+ context: dict = field(default_factory=dict)
226
+ form_id: str = 'form'
227
+ add_default_actions: bool = True
228
+ discard_url: str = None
229
+ actions: list[ClientAction] = field(default_factory=list)
230
+
231
+ def get_default_form_actions(self):
232
+ actions = [
233
+ ClientAction(
234
+ name=_('Save'),
235
+ submit=True,
236
+ class_list=['is-success'],
237
+ form_id=self.form_id
238
+ )
239
+ ]
240
+ try:
241
+ url = self.discard_url or self.model.get_absolute_url()
242
+ except TypeError:
243
+ raise TypeError(
244
+ 'Supply the discard_url parameter if FormContext is called '
245
+ 'with a model class instead of an instance.'
246
+ )
247
+ except AttributeError as e:
248
+ _logger.error(
249
+ 'Supply the discard_url parameter if FormContext is '
250
+ 'called with a model instance that has the get_absolute_url '
251
+ 'method not defined.'
252
+ )
253
+ raise e
254
+
255
+ actions.append(
256
+ ClientAction(
257
+ name=_('Discard'),
258
+ url=url,
259
+ )
260
+ )
261
+ return actions
262
+
263
+ def get_title(self):
264
+ if self.title:
265
+ return self.title
266
+ try:
267
+ int(self.model.pk)
268
+ return f'Edit {self.model}'
269
+ except TypeError:
270
+ return f'Add {self.model._meta.verbose_name}'
271
+
272
+ def dict(self):
273
+ ctx = {
274
+ 'title': self.get_title(),
275
+ 'view_type': 'form',
276
+ 'form_id': self.form_id,
277
+ 'querystring': build_querystring(self.get_params, ['page']),
278
+ 'actions': []
279
+ }
280
+ if self.add_default_actions:
281
+ ctx.update({'actions': self.get_default_form_actions()})
282
+ ctx['actions'].extend(self.actions)
283
+ ctx.update(self.context)
284
+ return ctx
@@ -244,10 +244,11 @@ class Filter:
244
244
  }
245
245
  ]
246
246
 
247
- def cast_decimal_places_to_step(self, descimal_places):
248
- if not descimal_places or descimal_places < 1:
247
+ @staticmethod
248
+ def cast_decimal_places_to_step(decimal_places):
249
+ if not decimal_places or decimal_places < 1:
249
250
  return '1'
250
- zero_count = descimal_places - 1
251
+ zero_count = decimal_places - 1
251
252
  return f'0.{"0" * zero_count}1'
252
253
 
253
254
  def get_query_term(self, field):
@@ -269,24 +270,22 @@ class Filter:
269
270
  elif internal_type in self.query_date_fields:
270
271
  return self.get_date_query_term(label, param)
271
272
 
272
- def get_searchable_method_term(self, model, method):
273
- method_name = method['name']
274
- param = '_c_' + method_name
275
- method_label = str(method.get('label', method_name.capitalize()))
276
- func = getattr(model, method['name'])
277
- return_type = func.__annotations__.get('return')
273
+ def get_annotation_term(self, model, annotation):
274
+ name = '_a_' + annotation['name']
275
+ label = annotation.get('label', name)
276
+ return_type = annotation['type']
278
277
  if return_type == str:
279
- return self.get_char_query_term(method_label, param)
278
+ return self.get_char_query_term(label, name)
280
279
  elif return_type == int:
281
- return self.get_int_query_term(method_label, param)
280
+ return self.get_int_query_term(label, name)
282
281
  elif return_type in [float, Decimal]:
283
- return self.get_float_query_term(method_label, param, method.get('step', 0))
282
+ return self.get_float_query_term(label, name, annotation.get('step', 0))
284
283
  elif return_type == bool:
285
- return self.get_boolean_query_term(method_label, param)
284
+ return self.get_boolean_query_term(label, name)
286
285
  elif return_type == datetime.datetime:
287
- return self.get_datetime_query_term(method_label, param)
286
+ return self.get_datetime_query_term(label, name)
288
287
  elif return_type == datetime.date:
289
- return self.get_date_query_term(method_label, param)
288
+ return self.get_date_query_term(label, name)
290
289
 
291
290
  def get_relation_query_terms(self, model, path):
292
291
  terms = []
@@ -328,8 +327,8 @@ class Filter:
328
327
  term['params'].extend(self.get_null_params())
329
328
  if term is not None:
330
329
  terms.append(term)
331
- for searchable_method in searchable_methods:
332
- terms.append(self.get_searchable_method_term(model, searchable_method))
330
+ for annotation in getattr(model, 'annotations', []):
331
+ terms.append(self.get_annotation_term(model, annotation))
333
332
  terms = sorted(terms, key=lambda x: x['label'])
334
333
  return terms
335
334
 
@@ -0,0 +1,93 @@
1
+ import json
2
+ import logging
3
+ import operator
4
+ from django.db.models import Model, Q
5
+
6
+ _logger = logging.getLogger(__name__)
7
+
8
+
9
+ def parse_querystring(model: type[Model], query_string: str) -> Q:
10
+
11
+ def get_expression(term: str, value) -> Q:
12
+ invert = False
13
+ if term.startswith('~'):
14
+ invert = True
15
+ term = term[1:]
16
+
17
+ parts = term.split('_a_')
18
+ if len(parts) == 1:
19
+ expression = Q(**{term: value})
20
+ return ~expression if invert else expression
21
+
22
+ rel_path = parts[0].rstrip('__')
23
+ term = parts[1]
24
+ rel_model = get_related_model(rel_path) if rel_path else model
25
+ objects = rel_model.objects.annotate(**{
26
+ annotation['name']: annotation['func']
27
+ for annotation in rel_model.annotations
28
+ }).filter(Q(**{term: value}))
29
+ expression = Q(**{
30
+ f'{rel_path}{"__" if rel_path else ""}id__in': objects.values_list('id', flat=True)
31
+ })
32
+
33
+ return ~expression if invert else expression
34
+
35
+ def get_related_model(rel_path: str):
36
+ related_model = model
37
+ for part in rel_path.split('__'):
38
+ try:
39
+ related_model = related_model._meta.fields_map[part].related_model
40
+ except (AttributeError, KeyError):
41
+ try:
42
+ related_model = getattr(related_model, part).field.related_model
43
+ except AttributeError:
44
+ break
45
+ return related_model
46
+
47
+ def parse_query_block(sub_item) -> Q:
48
+ op = ops['&']
49
+ parsed_query = Q()
50
+ for item in sub_item:
51
+ if isinstance(item, list):
52
+ parsed_query = op(parsed_query, parse_query_block(item))
53
+ elif isinstance(item, dict):
54
+ dict_query = Q()
55
+ for term, value in item.items():
56
+ dict_query = ops['&'](dict_query, get_expression(term, value))
57
+ parsed_query = op(parsed_query, dict_query)
58
+ elif isinstance(item, str):
59
+ try:
60
+ op = ops[item]
61
+ except KeyError as e:
62
+ _logger.exception(e)
63
+ raise ValueError(
64
+ f'Invalid operator in querystring: {item}.'
65
+ f'Operator must be one of &, |, ^'
66
+ )
67
+ else:
68
+ raise ValueError(
69
+ f'Unsupported item in querystring: {item}'
70
+ )
71
+ return parsed_query
72
+
73
+ query_data = json.loads(query_string)
74
+ if isinstance(query_data, dict):
75
+ query_data = [query_data]
76
+
77
+ ops = {'&': operator.and_, '|': operator.or_, '^': operator.xor}
78
+ query = parse_query_block(query_data)
79
+ return query
80
+
81
+
82
+ def build_querystring(get_params: dict, extra_params: list[str] = None) -> str:
83
+ querystring = f'?q={get_params.get("q", "[]")}'
84
+ if paginate_by := get_params.get('paginate_by', False):
85
+ querystring += f'&paginate_by={paginate_by}'
86
+ if order_by := get_params.get('order_by', False):
87
+ querystring += f'&order_by={order_by}'
88
+ if crumbs := get_params.get('crumbs', False):
89
+ querystring += f'&crumbs={crumbs}'
90
+ for param in extra_params or []:
91
+ if value := get_params.get(param, False):
92
+ querystring += f'&{param}={value}'
93
+ return querystring