django-smartbase-admin 1.0.33__py3-none-any.whl → 1.0.34__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.
@@ -4,6 +4,7 @@ import urllib.parse
4
4
  from collections.abc import Iterable
5
5
  from functools import partial
6
6
  from typing import Any
7
+ from urllib.parse import urlparse
7
8
 
8
9
  from ckeditor.fields import RichTextFormField
9
10
  from ckeditor_uploader.fields import RichTextUploadingFormField
@@ -30,7 +31,7 @@ from django.forms.models import (
30
31
  from django.http import HttpResponse
31
32
  from django.template.loader import render_to_string
32
33
  from django.template.response import TemplateResponse
33
- from django.urls import reverse, NoReverseMatch
34
+ from django.urls import reverse, NoReverseMatch, resolve
34
35
  from django.utils.safestring import mark_safe, SafeString
35
36
  from django.utils.text import capfirst
36
37
  from django.utils.translation import gettext_lazy as _
@@ -126,6 +127,7 @@ from django_smartbase_admin.engine.admin_base_view import (
126
127
  SBADMIN_PARENT_INSTANCE_PK_VAR,
127
128
  SBADMIN_PARENT_INSTANCE_LABEL_VAR,
128
129
  SBADMIN_PARENT_INSTANCE_FIELD_NAME_VAR,
130
+ SBADMIN_RELOAD_ON_SAVE_VAR,
129
131
  )
130
132
  from django_smartbase_admin.engine.const import (
131
133
  OBJECT_ID_PLACEHOLDER,
@@ -316,6 +318,13 @@ class SBAdminBaseFormInit(SBAdminFormFieldWidgetsMixin, FormFieldsetMixin):
316
318
  "request", SBAdminThreadLocalService.get_request()
317
319
  )
318
320
  super().__init__(*args, **kwargs)
321
+ self.init_widgets_dynamic(threadsafe_request)
322
+ for field in self.declared_fields:
323
+ form_field = self.fields.get(field)
324
+ if form_field:
325
+ self.assign_widget_to_form_field(form_field, request=threadsafe_request)
326
+
327
+ def init_widgets_dynamic(self, request):
319
328
  for field in self.fields:
320
329
  if not hasattr(self.fields[field].widget, "init_widget_dynamic"):
321
330
  continue
@@ -324,12 +333,8 @@ class SBAdminBaseFormInit(SBAdminFormFieldWidgetsMixin, FormFieldsetMixin):
324
333
  self.fields[field],
325
334
  field,
326
335
  self.view,
327
- threadsafe_request,
336
+ request,
328
337
  )
329
- for field in self.declared_fields:
330
- form_field = self.fields.get(field)
331
- if form_field:
332
- self.assign_widget_to_form_field(form_field, request=threadsafe_request)
333
338
 
334
339
 
335
340
  class SBAdminBaseForm(SBAdminBaseFormInit, forms.ModelForm):
@@ -822,7 +827,14 @@ class SBAdmin(
822
827
  return Q()
823
828
 
824
829
  def get_change_view_context(self, request, object_id) -> dict | dict[str, Any]:
825
- return {"show_back_button": True}
830
+ return {
831
+ "show_back_button": True,
832
+ "back_url": reverse(
833
+ "sb_admin:{}_{}_changelist".format(
834
+ self.opts.app_label, self.opts.model_name
835
+ )
836
+ ),
837
+ }
826
838
 
827
839
  def get_previous_next_context(self, request, object_id) -> dict | dict[str, Any]:
828
840
  if not self.sbadmin_previous_next_buttons_enabled or not object_id:
@@ -958,7 +970,7 @@ class SBAdmin(
958
970
  "field": request.POST.get("sb_admin_source_field"),
959
971
  "id": obj.pk,
960
972
  "label": str(obj),
961
- "reload": request.POST.get("sbadmin_reload_on_save") == "1",
973
+ "reload": request.POST.get(SBADMIN_RELOAD_ON_SAVE_VAR) == "1",
962
974
  },
963
975
  )
964
976
  trigger_client_event(response, "hideModal", {"elt": "sb-admin-modal"})
@@ -1051,6 +1063,31 @@ class SBAdminInline(
1051
1063
  self.initialize_form_class(form_class, request)
1052
1064
  form_class()
1053
1065
 
1066
+ def get_parent_instance_from_request(self):
1067
+ # Try to get parent instance from request referrer
1068
+ request = (
1069
+ getattr(self, "threadsafe_request", None)
1070
+ or SBAdminThreadLocalService.get_request()
1071
+ )
1072
+ allowed = SBAdminViewService.has_permission(
1073
+ request=request, model=self.parent_model, permission="view"
1074
+ )
1075
+ if not allowed:
1076
+ return None
1077
+
1078
+ referer = request.META.get("HTTP_REFERER")
1079
+ if not referer:
1080
+ return None
1081
+ resolved = resolve(urlparse(referer).path)
1082
+ # Try common kwargs for object ID
1083
+ object_id = resolved.kwargs.get("object_id")
1084
+ if not object_id:
1085
+ return None
1086
+ base_qs = SBAdminViewService.get_restricted_queryset(
1087
+ self.parent_model, request, request.request_data
1088
+ )
1089
+ return base_qs.get(pk=object_id)
1090
+
1054
1091
  def get_context_data(self, request) -> dict[str, Any]:
1055
1092
  is_sortable_active: bool = self.sortable_field_name and (
1056
1093
  self.has_add_permission(request) or self.has_change_permission(request)
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import logging
3
+ import sys
3
4
 
4
5
  from ckeditor.widgets import CKEditorWidget
5
6
  from ckeditor_uploader.widgets import CKEditorUploadingWidget
@@ -10,7 +11,7 @@ from django.contrib.admin.widgets import (
10
11
  ForeignKeyRawIdWidget,
11
12
  )
12
13
  from django.contrib.auth.forms import ReadOnlyPasswordHashWidget
13
- from django.core.exceptions import ValidationError
14
+ from django.core.exceptions import ValidationError, ImproperlyConfigured
14
15
  from django.template.loader import render_to_string
15
16
  from django.urls import reverse
16
17
  from django.utils.formats import get_format
@@ -358,11 +359,19 @@ class SBAdminAutocompleteWidget(
358
359
  form = None
359
360
  field_name = None
360
361
  initialised = None
362
+ default_create_data = None
363
+ reload_on_save = None
364
+ REQUEST_CREATED_DATA_KEY = "autocomplete_created_data"
361
365
 
362
366
  def __init__(self, form_field=None, *args, **kwargs):
363
367
  attrs = kwargs.pop("attrs", None)
368
+ self.reload_on_save = kwargs.pop("reload_on_save", False)
364
369
  super().__init__(form_field, *args, **kwargs)
365
370
  self.attrs = {} if attrs is None else attrs.copy()
371
+ if self.multiselect and self.allow_add:
372
+ raise ImproperlyConfigured(
373
+ "Multiselect with creation is currently not supported."
374
+ )
366
375
 
367
376
  def get_id(self):
368
377
  base_id = super().get_id()
@@ -370,7 +379,9 @@ class SBAdminAutocompleteWidget(
370
379
  base_id += f"_{self.form.__class__.__name__}"
371
380
  return base_id
372
381
 
373
- def init_widget_dynamic(self, form, form_field, field_name, view, request):
382
+ def init_widget_dynamic(
383
+ self, form, form_field, field_name, view, request, default_create_data=None
384
+ ):
374
385
  super().init_widget_dynamic(form, form_field, field_name, view, request)
375
386
  if self.initialised:
376
387
  return
@@ -378,6 +389,7 @@ class SBAdminAutocompleteWidget(
378
389
  self.field_name = field_name
379
390
  self.view = view
380
391
  self.form = form
392
+ self.default_create_data = default_create_data or {}
381
393
  self.init_autocomplete_widget_static(
382
394
  self.field_name,
383
395
  self.model,
@@ -416,20 +428,50 @@ class SBAdminAutocompleteWidget(
416
428
  parsed_value = None
417
429
  if value:
418
430
  parsed_value = self.parse_value_from_input(threadsafe_request, value)
431
+ is_create = self.parse_is_create_from_input(
432
+ threadsafe_request,
433
+ threadsafe_request.request_data.request_post.get(name),
434
+ )
435
+ selected_options = []
436
+ if is_create:
437
+ errors = getattr(self.form, "errors", {})
438
+ if errors.get(self.field_name):
439
+ parsed_value = None
419
440
  if parsed_value:
420
- selected_options = []
421
- for item in self.get_queryset(threadsafe_request).filter(
422
- **{f"{self.get_value_field()}{query_suffix}": parsed_value}
423
- ):
424
- selected_options.append(
425
- {
426
- "value": self.get_value(threadsafe_request, item),
427
- "label": self.get_label(threadsafe_request, item),
428
- }
429
- )
430
-
431
- context["widget"]["value"] = json.dumps(selected_options)
432
- context["widget"]["value_list"] = selected_options
441
+ if self.is_multiselect() and not isinstance(parsed_value, list):
442
+ parsed_value = [parsed_value]
443
+
444
+ try:
445
+ for item in self.get_queryset(threadsafe_request).filter(
446
+ **{f"{self.get_value_field()}{query_suffix}": parsed_value}
447
+ ):
448
+ selected_options.append(
449
+ {
450
+ "value": self.get_value(threadsafe_request, item),
451
+ "label": self.get_label(threadsafe_request, item),
452
+ }
453
+ )
454
+ except ValueError as e:
455
+ new_object_id = threadsafe_request.request_data.additional_data.get(
456
+ self.REQUEST_CREATED_DATA_KEY, {}
457
+ ).get(self.field_name)
458
+ if new_object_id:
459
+ selected_options.append(
460
+ {
461
+ "value": new_object_id,
462
+ "label": value,
463
+ }
464
+ )
465
+ elif hasattr(self.form, "add_error"):
466
+ self.form.add_error(
467
+ self.field_name,
468
+ _(
469
+ "The new value was created but became unselected due to another validation error. Please select it again."
470
+ ),
471
+ )
472
+
473
+ context["widget"]["value"] = json.dumps(selected_options)
474
+ context["widget"]["value_list"] = selected_options
433
475
 
434
476
  if (
435
477
  threadsafe_request.request_data.configuration.autocomplete_show_related_buttons(
@@ -441,6 +483,7 @@ class SBAdminAutocompleteWidget(
441
483
  and not self.is_multiselect()
442
484
  ):
443
485
  self.add_related_buttons_urls(parsed_value, threadsafe_request, context)
486
+ context["reload_on_save"] = self.reload_on_save
444
487
 
445
488
  return context
446
489
 
@@ -472,13 +515,165 @@ class SBAdminAutocompleteWidget(
472
515
  model_field = getattr(self.field, "model_field", None)
473
516
  return not (model_field and (model_field.one_to_one or model_field.many_to_one))
474
517
 
518
+ def _is_in_validation_context(self):
519
+ """
520
+ Check if value_from_datadict is being called during form validation
521
+ (full_clean, _clean_fields, etc.) vs. during change detection by formsets.
522
+
523
+ Returns True if called during actual validation, False if called during
524
+ change detection or other non-validation contexts.
525
+
526
+ Uses sys._getframe() instead of inspect.currentframe() for better performance,
527
+ as this method is called frequently during form processing.
528
+ """
529
+ # Get the call stack - using sys._getframe() for better performance
530
+ # sys._getframe(1) gets the caller's frame (skipping this method)
531
+ try:
532
+ current_frame = sys._getframe(1)
533
+ except ValueError:
534
+ # Fallback if _getframe is not available (unlikely in CPython)
535
+ return False
536
+
537
+ # Look for validation-related methods in the call stack
538
+ validation_methods = {
539
+ "_clean_bound_field",
540
+ }
541
+
542
+ # Walk up the call stack
543
+ depth = 0
544
+ while current_frame and depth < 5: # Limit depth to avoid infinite loops
545
+ method_name = current_frame.f_code.co_name
546
+ if method_name in validation_methods:
547
+ return True
548
+ current_frame = current_frame.f_back
549
+ depth += 1
550
+
551
+ return False
552
+
553
+ def get_forward_data(self, request, name):
554
+ """
555
+ Parse forward data from request.request_data.request_post.
556
+
557
+ For each field in self.forward, use name as base field name and replace
558
+ in it current field name with forward field name, return dict.
559
+
560
+ Args:
561
+ request: The request object
562
+ name: The base field name (e.g., "product__category")
563
+
564
+ Returns:
565
+ dict: Forward data with keys being forward field names and values
566
+ from request data
567
+ """
568
+ forward_data = {}
569
+ if not getattr(self, "forward", None):
570
+ return forward_data
571
+
572
+ post_data = getattr(request.request_data, "request_post", {})
573
+ if not post_data:
574
+ return forward_data
575
+
576
+ # For each field in self.forward list
577
+ for forward_field in self.forward:
578
+ # Replace only from end of name, separated by last -
579
+ # Example: if name="prefix-field_name", self.field_name="field_name",
580
+ # forward_field="parent" -> result="prefix-parent"
581
+ name_parts = name.split("-")
582
+
583
+ # Replace only if the last part matches self.field_name
584
+ if name_parts and name_parts[-1] == self.field_name:
585
+ # Replace the last part with forward_field and join back
586
+ name_parts[-1] = forward_field
587
+ forward_field_name = "-".join(name_parts)
588
+ else:
589
+ # If last part doesn't match, don't create forward field name
590
+ continue
591
+
592
+ # Get value from post_data if it exists
593
+ if forward_field_name in post_data:
594
+ forward_data[forward_field] = post_data.get(forward_field_name)
595
+
596
+ return forward_data
597
+
475
598
  def value_from_datadict(self, data, files, name):
476
599
  input_value = super().value_from_datadict(data, files, name)
477
600
  threadsafe_request = SBAdminThreadLocalService.get_request()
478
601
  parsed_value = self.parse_value_from_input(threadsafe_request, input_value)
479
602
  if parsed_value is None:
480
603
  return parsed_value
481
- return parsed_value if self.is_multiselect() else next(iter(parsed_value), None)
604
+
605
+ if not self.is_multiselect():
606
+ parsed_value = next(iter(parsed_value), None)
607
+
608
+ # Only perform validation during actual form cleaning, not during change detection
609
+ # by inline formsets or during HTML rendering
610
+ is_in_validation = self._is_in_validation_context()
611
+ if is_in_validation:
612
+ try:
613
+ has_changed = self.form_field.has_changed(
614
+ self.form.initial.get(self.field_name, None), parsed_value
615
+ )
616
+ except AttributeError:
617
+ has_changed = False
618
+ if has_changed:
619
+ parsed_is_create = self.parse_is_create_from_input(
620
+ threadsafe_request, input_value
621
+ )
622
+ if not self.is_multiselect():
623
+ parsed_is_create = next(iter(parsed_is_create), None)
624
+ base_qs = self.get_queryset(threadsafe_request)
625
+ forward_data = self.get_forward_data(threadsafe_request, name)
626
+ qs = self.filter_search_queryset(
627
+ threadsafe_request,
628
+ base_qs,
629
+ forward_data=forward_data,
630
+ )
631
+ self.form_field.queryset = qs
632
+ parsed_value = self.validate(
633
+ parsed_value, qs, threadsafe_request, parsed_is_create
634
+ )
635
+
636
+ return parsed_value
637
+
638
+ def should_create_new_obj(self):
639
+ return self.allow_add and self.create_value_field
640
+
641
+ def create_new_obj(self, value, queryset, is_create):
642
+ if isinstance(value, list):
643
+ # TODO: multiselect creation
644
+ return self.form_field.to_python(value)
645
+ else:
646
+ data_to_create = {
647
+ self.create_value_field: value,
648
+ **self.default_create_data,
649
+ }
650
+ new_obj = queryset.model.objects.create(**data_to_create)
651
+ try:
652
+ return self.form_field.to_python(new_obj.id)
653
+ except ValidationError:
654
+ new_obj.delete()
655
+ raise ValidationError(
656
+ self.form_field.error_messages["invalid_choice"],
657
+ code="invalid_choice",
658
+ params={"value": value},
659
+ )
660
+
661
+ def validate(self, value, queryset, request, is_create=False):
662
+ is_create_value = (
663
+ True in is_create if isinstance(is_create, list) else is_create
664
+ )
665
+ if is_create_value and self.should_create_new_obj():
666
+ new_object = self.create_new_obj(value, queryset, is_create)
667
+ request.request_data.additional_data[self.REQUEST_CREATED_DATA_KEY] = (
668
+ request.request_data.additional_data.get(
669
+ self.REQUEST_CREATED_DATA_KEY, {}
670
+ )
671
+ )
672
+ request.request_data.additional_data[self.REQUEST_CREATED_DATA_KEY][
673
+ self.field_name
674
+ ] = new_object.pk
675
+ return new_object
676
+ return self.form_field.to_python(value)
482
677
 
483
678
  @classmethod
484
679
  def apply_to_model_field(cls, model_field):
@@ -99,7 +99,7 @@ class SBAdminRoleConfiguration(metaclass=Singleton):
99
99
 
100
100
  for name, view in plugin_pool.plugins.items():
101
101
  if hasattr(view, "get_id"):
102
- view_instance = view()
102
+ view_instance = view(view.model, sb_admin_site)
103
103
  self.view_map[view_instance.get_id()] = view_instance
104
104
  view_instance.init_view_static(
105
105
  self, view_instance.model, sb_admin_site
@@ -2,6 +2,7 @@ import json
2
2
  from datetime import datetime, timedelta
3
3
 
4
4
  from django.core.exceptions import ImproperlyConfigured
5
+ from django.contrib.postgres.fields import ArrayField
5
6
  from django.db.models import Q, fields, FilteredRelation, Count
6
7
  from django.http import JsonResponse
7
8
  from django.utils import timezone
@@ -45,6 +46,22 @@ class AutocompleteParseMixin:
45
46
  value = input_value
46
47
  return value
47
48
 
49
+ def parse_is_create_from_input(self, request, input_value):
50
+ try:
51
+ input_value = json.loads(input_value)
52
+ except:
53
+ pass
54
+ if isinstance(input_value, list):
55
+ value = []
56
+ for data in input_value:
57
+ if type(data) is dict:
58
+ value.append(data.get("create", False))
59
+ else:
60
+ value.append(False)
61
+ else:
62
+ value = False
63
+ return value
64
+
48
65
 
49
66
  class SBAdminFilterWidget(JSONSerializableMixin):
50
67
  template_name = None
@@ -212,6 +229,8 @@ class ChoiceFilterWidget(SBAdminFilterWidget):
212
229
  return found_label[0] if found_label else default_value
213
230
 
214
231
  def get_base_filter_query_for_parsed_value(self, request, filter_value):
232
+ if isinstance(self.model_field, ArrayField):
233
+ return Q(**{f"{self.field.filter_field}__contains": [filter_value]})
215
234
  return Q(**{self.field.filter_field: filter_value})
216
235
 
217
236
 
@@ -248,6 +267,11 @@ class MultipleChoiceFilterWidget(AutocompleteParseMixin, ChoiceFilterWidget):
248
267
  self.select_all_label = select_all_label
249
268
 
250
269
  def get_base_filter_query_for_parsed_value(self, request, filter_value):
270
+ if isinstance(self.model_field, ArrayField):
271
+ q_objects = Q()
272
+ for value in filter_value:
273
+ q_objects |= Q(**{f"{self.field.filter_field}__contains": [value]})
274
+ return q_objects
251
275
  return Q(**{f"{self.field.filter_field}__in": filter_value})
252
276
 
253
277
  def get_advanced_filter_operators(self):
@@ -471,6 +495,7 @@ class AutocompleteFilterWidget(
471
495
  allow_add = False
472
496
  hide_clear_button = False
473
497
  search_query_lambda = None
498
+ create_value_field = None
474
499
 
475
500
  def get_field_name(self):
476
501
  return self.field.name
@@ -493,13 +518,16 @@ class AutocompleteFilterWidget(
493
518
  allow_add=None,
494
519
  hide_clear_button=None,
495
520
  search_query_lambda=None,
521
+ create_value_field=None,
496
522
  **kwargs,
497
523
  ) -> None:
498
524
  super().__init__(template_name, default_value, **kwargs)
499
525
  self.model = model or self.model
500
526
  self.value_field = value_field or self.value_field
501
527
  self.filter_query_lambda = filter_query_lambda or self.filter_query_lambda
528
+ # filters queryset to search in
502
529
  self.filter_search_lambda = filter_search_lambda or self.filter_search_lambda
530
+ # defines fields to search on
503
531
  self.search_query_lambda = search_query_lambda or self.search_query_lambda
504
532
  self.label_lambda = label_lambda or self.label_lambda
505
533
  self.value_lambda = value_lambda or self.value_lambda
@@ -507,6 +535,7 @@ class AutocompleteFilterWidget(
507
535
  self.multiselect = self.multiselect if self.multiselect is not None else True
508
536
  self.forward = forward or self.forward
509
537
  self.allow_add = allow_add or self.allow_add
538
+ self.create_value_field = create_value_field or self.create_value_field
510
539
  self.hide_clear_button = (
511
540
  hide_clear_button
512
541
  if hide_clear_button is not None
@@ -615,8 +644,9 @@ class AutocompleteFilterWidget(
615
644
  def get_value_field(self):
616
645
  return self.value_field or self.model._meta.pk.name
617
646
 
618
- def filter_search_queryset(self, request, qs, search_term, forward_data):
647
+ def filter_search_queryset(self, request, qs, search_term="", forward_data=None):
619
648
  if self.filter_search_lambda:
649
+ forward_data = forward_data or {}
620
650
  qs = qs.filter(
621
651
  self.filter_search_lambda(request, search_term, forward_data)
622
652
  )
@@ -628,9 +658,16 @@ class AutocompleteFilterWidget(
628
658
  page_num = int(post_data.get(AUTOCOMPLETE_PAGE_NUM, 1))
629
659
  from_item = (page_num - 1) * AUTOCOMPLETE_PAGE_SIZE
630
660
  to_item = (page_num) * AUTOCOMPLETE_PAGE_SIZE
661
+
662
+ # filter queryset
663
+ # base restricted queryset
631
664
  qs = self.get_queryset(request)
665
+ # filters queryset to search in, uses filter_search_lambda
632
666
  qs = self.filter_search_queryset(request, qs, search_term, forward_data)
667
+
668
+ # search in queryset
633
669
  if self.search_query_lambda:
670
+ # defines fields to search on
634
671
  qs = self.search_query_lambda(
635
672
  request,
636
673
  qs,
@@ -639,6 +676,7 @@ class AutocompleteFilterWidget(
639
676
  SBAdminTranslationsService.get_main_lang_code(),
640
677
  )
641
678
  else:
679
+ # defines default fields to search on - all char fields
642
680
  qs = self.get_default_search_query(
643
681
  request,
644
682
  qs,
@@ -20,6 +20,7 @@ class SBAdminViewRequestData(object):
20
20
  configuration = None
21
21
  selected_view = None
22
22
  session = None
23
+ additional_data = None
23
24
 
24
25
  def __init__(
25
26
  self,
@@ -47,6 +48,7 @@ class SBAdminViewRequestData(object):
47
48
  self.request_method = request_method
48
49
  self.global_filter = global_filter or {}
49
50
  self.session = session or {}
51
+ self.additional_data = {}
50
52
 
51
53
  def refresh_selected_view(self, request):
52
54
  self.configuration = SBAdminConfigurationService.get_configuration(self)
@@ -141,7 +141,7 @@ class SBAdminViewService(object):
141
141
  )
142
142
 
143
143
  @classmethod
144
- def has_permission(cls, request, view, model=None, obj=None, permission=None):
144
+ def has_permission(cls, request, view=None, model=None, obj=None, permission=None):
145
145
  return request.request_data.configuration.has_permission(
146
146
  request, request.request_data, view, model, obj, permission
147
147
  )
@@ -154,8 +154,9 @@ class SBAdminViewService(object):
154
154
  request_data,
155
155
  global_filter=True,
156
156
  global_filter_data_map=None,
157
+ qs=None,
157
158
  ):
158
- qs = model.objects.all()
159
+ qs = qs or model.objects.all()
159
160
  if global_filter:
160
161
  qs = cls.apply_global_filter_to_queryset(
161
162
  qs, request, request_data, global_filter_data_map