wbcore 1.54.10__py2.py3-none-any.whl → 1.58.2__py2.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 (153) hide show
  1. wbcore/cache/decorators.py +3 -3
  2. wbcore/cache/registry.py +3 -2
  3. wbcore/configs/decorators.py +1 -1
  4. wbcore/configurations/configurations/apps.py +2 -2
  5. wbcore/configurations/configurations/authentication.py +1 -1
  6. wbcore/configurations/configurations/base.py +1 -1
  7. wbcore/configurations/configurations/cache.py +1 -1
  8. wbcore/configurations/configurations/maintenance.py +1 -1
  9. wbcore/configurations/configurations/media.py +1 -1
  10. wbcore/configurations/configurations/middleware.py +1 -1
  11. wbcore/configurations/configurations/rest_framework.py +1 -1
  12. wbcore/configurations/configurations/static.py +1 -1
  13. wbcore/configurations/configurations/wbcore.py +1 -1
  14. wbcore/content_type/serializers.py +1 -1
  15. wbcore/content_type/utils.py +3 -3
  16. wbcore/contrib/agenda/viewsets/calendar_items.py +7 -7
  17. wbcore/contrib/ai/llm/config.py +1 -1
  18. wbcore/contrib/authentication/admin.py +2 -2
  19. wbcore/contrib/authentication/filters.py +0 -1
  20. wbcore/contrib/authentication/models/users.py +3 -3
  21. wbcore/contrib/authentication/models/users_activities.py +1 -1
  22. wbcore/contrib/authentication/serializers/users.py +2 -2
  23. wbcore/contrib/authentication/tests/test_tokens.py +3 -3
  24. wbcore/contrib/authentication/tests/test_users.py +0 -1
  25. wbcore/contrib/authentication/viewsets/user_activities.py +2 -1
  26. wbcore/contrib/authentication/viewsets/users.py +6 -4
  27. wbcore/contrib/color/models.py +2 -1
  28. wbcore/contrib/currency/factories.py +1 -1
  29. wbcore/contrib/currency/import_export/backends/fixerio/currency_fx_rates.py +3 -1
  30. wbcore/contrib/currency/models.py +28 -8
  31. wbcore/contrib/currency/serializers.py +5 -1
  32. wbcore/contrib/currency/tests/test_serializers.py +7 -3
  33. wbcore/contrib/currency/tests/test_viewsets.py +1 -1
  34. wbcore/contrib/currency/viewsets/currency.py +2 -2
  35. wbcore/contrib/dataloader/utils.py +2 -2
  36. wbcore/contrib/directory/factories/__init__.py +1 -1
  37. wbcore/contrib/directory/factories/entries.py +1 -1
  38. wbcore/contrib/directory/models/contacts.py +2 -2
  39. wbcore/contrib/directory/models/entries.py +18 -4
  40. wbcore/contrib/directory/models/relationships.py +25 -30
  41. wbcore/contrib/directory/permissions.py +6 -0
  42. wbcore/contrib/directory/serializers/companies.py +15 -8
  43. wbcore/contrib/directory/serializers/contacts.py +8 -8
  44. wbcore/contrib/directory/serializers/entries.py +24 -15
  45. wbcore/contrib/directory/serializers/entry_representations.py +4 -2
  46. wbcore/contrib/directory/serializers/persons.py +8 -9
  47. wbcore/contrib/directory/serializers/relationships.py +2 -2
  48. wbcore/contrib/directory/tests/conftest.py +2 -0
  49. wbcore/contrib/directory/tests/disable_signals.py +11 -1
  50. wbcore/contrib/directory/tests/signals.py +2 -2
  51. wbcore/contrib/directory/tests/test_models.py +88 -66
  52. wbcore/contrib/directory/tests/test_serializers.py +1 -1
  53. wbcore/contrib/directory/tests/test_viewsets.py +8 -8
  54. wbcore/contrib/directory/viewsets/buttons/__init__.py +1 -1
  55. wbcore/contrib/directory/viewsets/buttons/relationships.py +32 -0
  56. wbcore/contrib/directory/viewsets/contacts.py +6 -6
  57. wbcore/contrib/directory/viewsets/display/__init__.py +1 -1
  58. wbcore/contrib/directory/viewsets/display/entries.py +51 -36
  59. wbcore/contrib/directory/viewsets/display/relationships.py +22 -22
  60. wbcore/contrib/directory/viewsets/entries.py +4 -5
  61. wbcore/contrib/directory/viewsets/previews/entries.py +3 -3
  62. wbcore/contrib/directory/viewsets/relationships.py +16 -2
  63. wbcore/contrib/directory/viewsets/titles/relationships.py +2 -3
  64. wbcore/contrib/documents/filters.py +0 -2
  65. wbcore/contrib/example_app/models.py +4 -4
  66. wbcore/contrib/example_app/serializers/person_team.py +4 -4
  67. wbcore/contrib/example_app/tests/e2e/test_teams.py +1 -1
  68. wbcore/contrib/geography/tests/test_viewsets.py +1 -1
  69. wbcore/contrib/guardian/tests/test_model_mixins.py +3 -3
  70. wbcore/contrib/guardian/tests/test_tasks.py +9 -9
  71. wbcore/contrib/guardian/tests/test_viewsets.py +2 -2
  72. wbcore/contrib/icons/backends/default.py +1 -0
  73. wbcore/contrib/icons/backends/material.py +1 -0
  74. wbcore/contrib/icons/icons.py +5 -8
  75. wbcore/contrib/io/exceptions.py +8 -0
  76. wbcore/contrib/io/import_export/backends/stream.py +2 -2
  77. wbcore/contrib/io/imports.py +10 -5
  78. wbcore/contrib/io/models.py +17 -14
  79. wbcore/contrib/io/serializers.py +2 -2
  80. wbcore/contrib/io/tests/test_backends.py +1 -1
  81. wbcore/contrib/io/tests/test_imports.py +1 -1
  82. wbcore/contrib/io/viewset_mixins.py +4 -4
  83. wbcore/contrib/notifications/dispatch.py +18 -7
  84. wbcore/contrib/pandas/filterset.py +8 -7
  85. wbcore/contrib/pandas/views.py +7 -5
  86. wbcore/contrib/tags/models/tags.py +4 -1
  87. wbcore/contrib/workflow/factories/display.py +2 -2
  88. wbcore/contrib/workflow/models/data.py +7 -4
  89. wbcore/contrib/workflow/models/process.py +2 -2
  90. wbcore/contrib/workflow/serializers/data.py +8 -8
  91. wbcore/contrib/workflow/tests/test_models/test_condition.py +1 -1
  92. wbcore/contrib/workflow/workflows/assignees.py +4 -4
  93. wbcore/dynamic_preferences_registry.py +23 -9
  94. wbcore/enums.py +2 -1
  95. wbcore/filters/fields/content_type.py +5 -4
  96. wbcore/filters/fields/datetime.py +34 -9
  97. wbcore/filters/fields/models.py +2 -2
  98. wbcore/filters/filterset.py +22 -6
  99. wbcore/filters/mixins.py +6 -2
  100. wbcore/forms.py +6 -6
  101. wbcore/fsm/markdown_extensions.py +1 -1
  102. wbcore/fsm/mixins.py +7 -4
  103. wbcore/markdown/models.py +8 -5
  104. wbcore/metadata/configs/buttons/bases.py +6 -6
  105. wbcore/metadata/configs/buttons/buttons.py +2 -1
  106. wbcore/metadata/configs/buttons/view_config.py +5 -3
  107. wbcore/metadata/configs/display/display.py +2 -2
  108. wbcore/metadata/configs/display/formatting.py +6 -7
  109. wbcore/metadata/configs/display/list_display.py +6 -7
  110. wbcore/metadata/configs/display/models.py +6 -0
  111. wbcore/metadata/configs/fields.py +6 -1
  112. wbcore/metadata/configs/filter_fields.py +12 -11
  113. wbcore/models/fields.py +2 -2
  114. wbcore/permissions/permissions.py +2 -2
  115. wbcore/permissions/utils.py +2 -2
  116. wbcore/reversion/viewsets/titles.py +4 -3
  117. wbcore/serializers/__init__.py +1 -0
  118. wbcore/serializers/fields/__init__.py +1 -0
  119. wbcore/serializers/fields/datetime.py +35 -6
  120. wbcore/serializers/fields/fields.py +1 -1
  121. wbcore/serializers/fields/fsm.py +1 -1
  122. wbcore/serializers/fields/list.py +1 -1
  123. wbcore/serializers/fields/mixins.py +13 -5
  124. wbcore/serializers/fields/related.py +4 -6
  125. wbcore/serializers/fields/text.py +1 -1
  126. wbcore/serializers/fields/types.py +1 -0
  127. wbcore/serializers/serializers.py +6 -2
  128. wbcore/tasks.py +2 -2
  129. wbcore/templates/wbcore/email_base_template.html +3 -3
  130. wbcore/test/e2e_helpers_methods/e2e_checks.py +10 -4
  131. wbcore/test/e2e_helpers_methods/e2e_helper_methods.py +4 -2
  132. wbcore/test/mixins.py +1 -1
  133. wbcore/test/tests.py +6 -9
  134. wbcore/test/utils.py +3 -4
  135. wbcore/tests/e2e/test_e2e.py +2 -2
  136. wbcore/tests/test_cache/test_decorators.py +3 -3
  137. wbcore/tests/test_configs.py +1 -1
  138. wbcore/tests/test_fields/test_number_fields.py +1 -1
  139. wbcore/tests/test_filters/test_mixins.py +3 -3
  140. wbcore/tests/test_models/test_mixins.py +1 -1
  141. wbcore/tests/test_utils/test_date.py +1 -1
  142. wbcore/tests/test_utils/test_date_builder.py +25 -1
  143. wbcore/utils/date.py +18 -2
  144. wbcore/utils/figures.py +2 -2
  145. wbcore/utils/models.py +3 -2
  146. wbcore/utils/reportlab.py +7 -0
  147. wbcore/utils/rrules.py +1 -1
  148. wbcore/utils/string_loader.py +1 -1
  149. wbcore/utils/strings.py +2 -2
  150. wbcore/viewsets/mixins.py +6 -4
  151. {wbcore-1.54.10.dist-info → wbcore-1.58.2.dist-info}/METADATA +2 -1
  152. {wbcore-1.54.10.dist-info → wbcore-1.58.2.dist-info}/RECORD +153 -151
  153. {wbcore-1.54.10.dist-info → wbcore-1.58.2.dist-info}/WHEEL +0 -0
@@ -117,7 +117,7 @@ class TestBackend:
117
117
  with sftpserver.serve_content(VFS):
118
118
  import_credential = import_credential_factory.create(
119
119
  username="user",
120
- password="password",
120
+ password="password", # noqa
121
121
  additional_resources={
122
122
  "host": sftpserver.host,
123
123
  "port": sftpserver.port,
@@ -128,7 +128,7 @@ class TestImportSourceModel:
128
128
  assert model.name != comparison_model.name
129
129
 
130
130
  def test_process_wrongly_formatted_import_data(self, handler, import_source):
131
- with pytest.raises(Exception):
131
+ with pytest.raises(KeyError):
132
132
  handler.process(dict(a=1, b="b"))
133
133
 
134
134
  def test_process_basic(self, handler, import_source, parser_handler_factory):
@@ -51,8 +51,8 @@ class ImportExportDRFMixin(ImportExportMixin):
51
51
  def model(self):
52
52
  try:
53
53
  return getattr(self.queryset, "model", None)
54
- except AttributeError:
55
- raise ParseError("Malformed Queryset")
54
+ except AttributeError as e:
55
+ raise ParseError("Malformed Queryset") from e
56
56
 
57
57
  @cached_property
58
58
  def opts(self):
@@ -84,12 +84,12 @@ class ImportExportDRFMixin(ImportExportMixin):
84
84
  with suppress(AttributeError, AssertionError):
85
85
  # we have to mocky patch the action to be "list" because sometime we differentiate the serializer to use
86
86
  previous_action = getattr(self, "action", "list")
87
- setattr(self, "action", "list")
87
+ self.action = "list"
88
88
  serializer_class = getattr(
89
89
  self, "serializer_class", self.get_serializer_class()
90
90
  ) # we prioritize the default serializer class attribute
91
91
  resource_kwargs["serializer_class_path"] = serializer_class.__module__ + "." + serializer_class.__name__
92
- setattr(self, "action", previous_action)
92
+ self.action = previous_action
93
93
  return resource_kwargs
94
94
 
95
95
  def _get_data_for_export(self, request, queryset, *args, **kwargs) -> tablib.Dataset:
@@ -1,5 +1,6 @@
1
1
  from typing import Iterable
2
2
 
3
+ from celery import shared_task
3
4
  from django.conf import settings
4
5
  from django.contrib.auth import get_user_model
5
6
  from django.db import transaction
@@ -43,22 +44,32 @@ def send_notification(
43
44
  if isinstance(users, User):
44
45
  users = [users]
45
46
  for user in users:
46
- notification_user_settings = NotificationTypeSetting.objects.filter(
47
- notification_type__code=code,
48
- user=user,
49
- )
50
- if user.is_active and notification_user_settings.exists():
47
+ notification_type = NotificationType.objects.get(code=code)
48
+ if (
49
+ user.is_active
50
+ and NotificationTypeSetting.objects.filter(notification_type=notification_type, user=user).exists()
51
+ ):
51
52
  if not endpoint:
52
53
  endpoint = reverse(reverse_name, reverse_args, reverse_kwargs) if reverse_name else None
53
54
  notification = Notification.objects.create(
54
55
  title=title,
55
56
  body=body,
56
57
  user=user,
57
- notification_type=NotificationType.objects.get(code=code),
58
+ notification_type=notification_type,
58
59
  endpoint=endpoint,
59
60
  sent=timezone.now(),
60
61
  )
61
- transaction.on_commit(lambda: send_notification_task.delay(notification.pk))
62
+ transaction.on_commit(
63
+ lambda notification_pk=notification.pk: send_notification_task.delay(notification_pk)
64
+ )
65
+
66
+
67
+ @shared_task()
68
+ def send_notification_as_task(code, title, body, user_id, **kwargs):
69
+ if not isinstance(user_id, list):
70
+ user_id = [user_id]
71
+ user = User.objects.filter(id__in=user_id)
72
+ send_notification(code, title, body, user, **kwargs)
62
73
 
63
74
 
64
75
  @receiver(handle_widget_sharing)
@@ -16,12 +16,13 @@ class PandasFilterSetMixin:
16
16
  queryset = self.filters[name].filter(queryset, value)
17
17
  except FieldError:
18
18
  pass
19
- assert isinstance(queryset, models.QuerySet), (
20
- "Expected '%s.%s' to return a QuerySet, but got a %s instead."
21
- % (
22
- type(self).__name__,
23
- name,
24
- type(queryset).__name__,
19
+ if not isinstance(queryset, models.QuerySet):
20
+ raise AssertionError(
21
+ "Expected '%s.%s' to return a QuerySet, but got a %s instead."
22
+ % (
23
+ type(self).__name__,
24
+ name,
25
+ type(queryset).__name__,
26
+ )
25
27
  )
26
- )
27
28
  return queryset
@@ -40,8 +40,8 @@ class PandasMixin(CacheMixin):
40
40
  @cached_property
41
41
  def df(self) -> pd.DataFrame:
42
42
  if not hasattr(self, "_df"):
43
- setattr(self, "_df", self._get_dataframe())
44
- return getattr(self, "_df")
43
+ self._df = self._get_dataframe()
44
+ return self._df
45
45
 
46
46
  # BASIC DATAFRAME GENERATION FRAMEWORK METHODS
47
47
  def filter_queryset(self, queryset: QuerySet) -> QuerySet:
@@ -80,11 +80,13 @@ class PandasMixin(CacheMixin):
80
80
  )
81
81
 
82
82
  def get_queryset(self):
83
- assert hasattr(self, "queryset"), "Either specify a queryset or implement the get_queryset method."
83
+ if not hasattr(self, "queryset"):
84
+ raise AssertionError("Either specify a queryset or implement the get_queryset method.")
84
85
  return self.queryset
85
86
 
86
87
  def get_dataframe(self, request, queryset, **kwargs):
87
- assert self.get_pandas_fields(request), "No pandas_fields specified"
88
+ if not self.get_pandas_fields(request):
89
+ raise AssertionError("No pandas_fields specified")
88
90
  return pd.DataFrame(queryset.values(*self.get_pandas_fields(request).to_dict().keys()))
89
91
 
90
92
  def manipulate_dataframe(self, df):
@@ -110,7 +112,7 @@ class PandasMixin(CacheMixin):
110
112
  df = pd.DataFrame(
111
113
  columns=[field.key for field in self.get_pandas_fields(self.request).fields]
112
114
  ) # if queryset is empty, we make sure the returning df contains all the columns to avoid keyerrors exception
113
- setattr(self, "_df", df)
115
+ self._df = df
114
116
  df = self.filter_dataframe(df, **kwargs)
115
117
  return df
116
118
 
@@ -17,7 +17,7 @@ class ManagedMixin(models.Model):
17
17
  abstract = True
18
18
 
19
19
 
20
- class Tag(ManagedMixin, ComplexToStringMixin, WBModel):
20
+ class Tag(ComplexToStringMixin, ManagedMixin):
21
21
  title = models.CharField(max_length=255)
22
22
 
23
23
  slug = models.CharField(max_length=255, null=True, blank=True)
@@ -35,6 +35,9 @@ class Tag(ManagedMixin, ComplexToStringMixin, WBModel):
35
35
  verbose_name = "Tag"
36
36
  verbose_name_plural = "Tags"
37
37
 
38
+ def __str__(self) -> str:
39
+ return super().__str__()
40
+
38
41
  @classmethod
39
42
  def get_endpoint_basename(cls):
40
43
  return "wbcore:tags:tag"
@@ -12,9 +12,9 @@ fake = Faker()
12
12
  def _generate_grid_areas() -> list[list[str]]:
13
13
  outer_list = []
14
14
  inner_list_length = random.randint(1, 5)
15
- for k in range(random.randint(1, 5)):
15
+ for _ in range(random.randint(1, 5)):
16
16
  inner_list = []
17
- for i in range(inner_list_length):
17
+ for _ in range(inner_list_length):
18
18
  inner_list.append(random.choice(PersonModelSerializer.Meta.fields))
19
19
  outer_list.append(inner_list)
20
20
  return outer_list
@@ -28,7 +28,10 @@ class Data(WBModel):
28
28
  serializers.DateTimeField(),
29
29
  serializers.BooleanField(),
30
30
  ]
31
- return {data_type: serializer_field for data_type, serializer_field in zip(cls, serializer_fields)}
31
+ return {
32
+ data_type: serializer_field
33
+ for data_type, serializer_field in zip(cls, serializer_fields, strict=False)
34
+ }
32
35
 
33
36
  @classmethod
34
37
  def get_cast_mapping(cls) -> dict:
@@ -39,7 +42,7 @@ class Data(WBModel):
39
42
  datetime.strptime,
40
43
  bool,
41
44
  ]
42
- return {data_type: cast_callable for data_type, cast_callable in zip(cls, cast_callables)}
45
+ return {data_type: cast_callable for data_type, cast_callable in zip(cls, cast_callables, strict=False)}
43
46
 
44
47
  workflow = models.ForeignKey(
45
48
  to="workflow.Workflow",
@@ -176,8 +179,8 @@ class Data(WBModel):
176
179
  format_str = "%d.%m.%Y %H:%M:%S"
177
180
  try:
178
181
  casted_object = data_object.strftime(format_str)
179
- except AttributeError:
180
- raise ValueError(gettext("Date(time) type selected but no date(time) object provided!"))
182
+ except AttributeError as e:
183
+ raise ValueError(gettext("Date(time) type selected but no date(time) object provided!")) from e
181
184
 
182
185
  elif data_type == cls.DataType.BOOL:
183
186
  if data_object is True:
@@ -27,7 +27,7 @@ class Process(WBModel):
27
27
  WBColor.GREEN_LIGHT.value,
28
28
  WBColor.RED_LIGHT.value,
29
29
  ]
30
- return [choice for choice in zip(cls, colors)]
30
+ return [choice for choice in zip(cls, colors, strict=False)]
31
31
 
32
32
  id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, verbose_name=_("UUID"))
33
33
  workflow = models.ForeignKey(
@@ -124,7 +124,7 @@ class ProcessStep(WBModel):
124
124
  WBColor.RED_LIGHT.value,
125
125
  WBColor.GREY.value,
126
126
  ]
127
- return [choice for choice in zip(cls, colors)]
127
+ return [choice for choice in zip(cls, colors, strict=False)]
128
128
 
129
129
  id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, verbose_name=_("UUID"))
130
130
  process = models.ForeignKey(
@@ -43,7 +43,7 @@ class DataModelSerializer(wb_serializers.ModelSerializer):
43
43
  if data_type:
44
44
  try:
45
45
  Data.cast_value_to_datatype(data_type, default)
46
- except ValueError:
46
+ except ValueError as e:
47
47
  if data_type == Data.DataType.DATE:
48
48
  raise ValidationError(
49
49
  {
@@ -51,7 +51,7 @@ class DataModelSerializer(wb_serializers.ModelSerializer):
51
51
  "Invalid default value for this data type. Please use a date formatted to 'day.month.year'."
52
52
  )
53
53
  }
54
- )
54
+ ) from None
55
55
  elif data_type == Data.DataType.DATETIME:
56
56
  raise ValidationError(
57
57
  {
@@ -59,8 +59,8 @@ class DataModelSerializer(wb_serializers.ModelSerializer):
59
59
  "Invalid default value for this data type. Please use a datetime formatted to 'day.month.year hour:minute:second' in the 24h format."
60
60
  )
61
61
  }
62
- )
63
- raise ValidationError({"default": _("Invalid default value for this data type.")})
62
+ ) from None
63
+ raise ValidationError({"default": _("Invalid default value for this data type.")}) from e
64
64
 
65
65
  return data
66
66
 
@@ -99,7 +99,7 @@ class DataValueModelSerializer(wb_serializers.ModelSerializer):
99
99
  if data_obj and value:
100
100
  try:
101
101
  Data.cast_value_to_datatype(data_obj.data_type, value)
102
- except ValueError:
102
+ except ValueError as e:
103
103
  if data_obj.data_type == Data.DataType.DATE:
104
104
  raise ValidationError(
105
105
  {
@@ -107,7 +107,7 @@ class DataValueModelSerializer(wb_serializers.ModelSerializer):
107
107
  "Invalid value for this data type. Please use a date formatted to 'day.month.year'."
108
108
  )
109
109
  }
110
- )
110
+ ) from None
111
111
  elif data_obj.data_type == Data.DataType.DATETIME:
112
112
  raise ValidationError(
113
113
  {
@@ -115,8 +115,8 @@ class DataValueModelSerializer(wb_serializers.ModelSerializer):
115
115
  "Invalid value for this data type. Please use a datetime formatted to 'day.month.year hour:minute:second' in the 24h format."
116
116
  )
117
117
  }
118
- )
119
- raise ValidationError({"value": _("Invalid value for this data type.")})
118
+ ) from None
119
+ raise ValidationError({"value": _("Invalid value for this data type.")}) from e
120
120
 
121
121
  return data
122
122
 
@@ -10,7 +10,7 @@ class TestCondition:
10
10
  def test_errors_satisfied_not_called(self, condition_factory):
11
11
  condition = condition_factory()
12
12
  with pytest.raises(ValueError):
13
- condition.errors
13
+ assert condition.errors == []
14
14
 
15
15
  def test_errors(self, condition_factory):
16
16
  condition = condition_factory()
@@ -59,14 +59,14 @@ def weighted_random(process_step: ProcessStep, **kwargs) -> User | None:
59
59
  # We redistribute each occurrence number between all of the other list items to increase their probability
60
60
  redistributed_list: list[int] = [0 for i in range(group_user_count)]
61
61
  for index, elem in enumerate(number_of_past_assignee_occurrences):
62
- for index2, elem2 in enumerate(redistributed_list):
62
+ for index2 in range(len(redistributed_list)):
63
63
  if not index2 == index:
64
64
  redistributed_list[index2] += elem / (group_user_count - 1) if elem else 0
65
65
  # Transform the list of absolute values into percentages
66
66
  new_weights: list[float] = [x / sum(redistributed_list) for x in redistributed_list]
67
- new_assignee: User = choices(group_member_list, weights=new_weights)[0]
67
+ new_assignee: User = choices(group_member_list, weights=new_weights)[0] # noqa
68
68
  else:
69
- new_assignee: User = choices(group_member_list)[0]
69
+ new_assignee: User = choices(group_member_list)[0] # noqa
70
70
  return new_assignee
71
71
 
72
72
  process_step.step.get_casted_step().set_failed(
@@ -79,7 +79,7 @@ def weighted_random(process_step: ProcessStep, **kwargs) -> User | None:
79
79
  @register_assignee("Random Group Member")
80
80
  def random_group_member(process_step: ProcessStep, **kwargs) -> User | None:
81
81
  if (group := process_step.group) and group.user_set.exists():
82
- return group.user_set.all()[randint(0, group.user_set.count() - 1)]
82
+ return group.user_set.all()[randint(0, group.user_set.count() - 1)] # noqa
83
83
 
84
84
  process_step.step.get_casted_step().set_failed(
85
85
  process_step,
@@ -5,6 +5,7 @@ from dynamic_preferences.types import IntegerPreference, StringPreference
5
5
  from dynamic_preferences.users.registries import user_preferences_registry
6
6
 
7
7
  from wbcore.contrib.dynamic_preferences.types import ChoicePreference, LanguageChoicePreference
8
+ from wbcore.utils.date import get_timezone_choices
8
9
 
9
10
  wbcore = Section("wbcore")
10
11
 
@@ -50,8 +51,21 @@ class LanguagePreference(LanguageChoicePreference):
50
51
 
51
52
 
52
53
  @user_preferences_registry.register
53
- class DateFormatPreference(ChoicePreference):
54
+ class TimezonePreference(ChoicePreference):
54
55
  weight = 0
56
+ # Value is a IANA timezone name
57
+ choices = get_timezone_choices()
58
+ section = wbcore
59
+ name = "timezone"
60
+ default = "Europe/Berlin"
61
+
62
+ verbose_name = _("Timezone")
63
+ help_text = _("Pick the timezone in which you want the workbench's dates to be displayed in.")
64
+
65
+
66
+ @user_preferences_registry.register
67
+ class DateFormatPreference(ChoicePreference):
68
+ weight = 1
55
69
  choices = [
56
70
  ("DD.MM.YYYY", "13.04.2007"),
57
71
  ("DD/MM/YYYY", "13/04/2007"),
@@ -77,7 +91,7 @@ class DateFormatPreference(ChoicePreference):
77
91
 
78
92
  @user_preferences_registry.register
79
93
  class TimeFormatPreference(ChoicePreference):
80
- weight = 1
94
+ weight = 2
81
95
  choices = [
82
96
  ("HH:mm", "14:05"),
83
97
  ("hh:mm", "02:05"),
@@ -100,18 +114,18 @@ class TimeFormatPreference(ChoicePreference):
100
114
 
101
115
  @user_preferences_registry.register
102
116
  class NumberFormatPreference(ChoicePreference):
103
- weight = 2
104
- # Value is a BCP 47 language tag
117
+ weight = 3
118
+ # Value is a BCP 47 region subtag
105
119
  choices = [
106
- ("en-US", "1,234,567.89"),
107
- ("fr-FR", "1\u202f234\u202f567,89"),
108
- ("de-DE", "1.234.567,89"),
109
- ("de-CH", "1’234’567.89"),
120
+ ("US", "1,234,567.89"),
121
+ ("FR", "1\u202f234\u202f567,89"),
122
+ ("DE", "1.234.567,89"),
123
+ ("CH", "1’234’567.89"),
110
124
  ]
111
125
 
112
126
  section = wbcore
113
127
  name = "number_format"
114
- default = "en-US"
128
+ default = "US"
115
129
 
116
130
  verbose_name = _("Number Format")
117
131
  help_text = _("Choose how you want numbers to appear throughout the Workbench.")
wbcore/enums.py CHANGED
@@ -39,7 +39,8 @@ class Unit(Enum):
39
39
  return (float(_value), self.value)
40
40
 
41
41
  def unit(self, _value: Union[float, str, int]):
42
- assert isinstance(_value, (float, str, int)), "_value needs to be one of str, float or int"
42
+ if not isinstance(_value, (float, str, int)):
43
+ raise AssertionError("_value needs to be one of str, float or int")
43
44
 
44
45
  return f"{float(_value)}{self.value}"
45
46
 
@@ -29,16 +29,17 @@ class MultipleChoiceContentTypeFilter(WBCoreFilterMixin, django_filters.Filter):
29
29
  def filter(self, qs, value):
30
30
  if value in EMPTY_VALUES:
31
31
  return qs
32
-
33
32
  conditions = [
34
33
  (
35
34
  Q(**{self.content_type_label: ContentType.objects.get_for_model(val)})
36
35
  & Q(**{self.object_id_label: val.id})
37
36
  )
38
- for val in value
37
+ for val in filter(None, value)
39
38
  ]
40
- qs = qs.filter(reduce(operator.or_, conditions))
41
-
39
+ if conditions:
40
+ qs = qs.filter(reduce(operator.or_, conditions))
41
+ else:
42
+ qs = qs.none()
42
43
  if self.distinct:
43
44
  qs = qs.distinct()
44
45
  return qs
@@ -1,6 +1,9 @@
1
1
  from contextlib import suppress
2
2
 
3
3
  import django_filters
4
+ from django.contrib.postgres.fields import RangeField
5
+ from django_filters.constants import EMPTY_VALUES
6
+ from django_filters.utils import get_model_field
4
7
 
5
8
  from wbcore.filters.mixins import WBCoreFilterMixin
6
9
  from wbcore.forms import DateRangeField, DateTimeRangeField
@@ -45,6 +48,13 @@ class DateRangeFilter(ShortcutAndPerformanceMixin, django_filters.Filter):
45
48
  kwargs.setdefault("lookup_expr", "overlap")
46
49
  super().__init__(*args, **kwargs)
47
50
 
51
+ @property
52
+ def is_range(self) -> bool:
53
+ if hasattr(self, "model"):
54
+ field = get_model_field(self.model, self.field_name)
55
+ return issubclass(field.__class__, RangeField)
56
+ return False
57
+
48
58
  def _get_initial(self, *args):
49
59
  initial = super()._get_initial(*args)
50
60
  if initial is not None:
@@ -70,23 +80,38 @@ class DateRangeFilter(ShortcutAndPerformanceMixin, django_filters.Filter):
70
80
 
71
81
  return representation, lookup_expr
72
82
 
73
- @classmethod
74
- def base_date_range_filter_method(cls, queryset, field_name, value):
83
+ def filter(self, qs, value):
84
+ if value in EMPTY_VALUES:
85
+ return qs
75
86
  if value:
87
+ lower, upper = value.lower, value.upper
76
88
  filters = {}
77
- if value.lower:
78
- filters[f"{field_name}__gte"] = value.lower
79
- if value.upper:
80
- filters[f"{field_name}__lte"] = value.upper
81
- return queryset.filter(**filters)
82
- return queryset
89
+ is_field_range = self.is_range
90
+ if lower:
91
+ if is_field_range:
92
+ filters[f"{self.field_name}__startswith__gte"] = lower
93
+ else:
94
+ filters[f"{self.field_name}__gte"] = lower
95
+
96
+ if upper:
97
+ if is_field_range:
98
+ filters[f"{self.field_name}__endswith__lte"] = upper
99
+ else:
100
+ filters[f"{self.field_name}__lte"] = upper
101
+
102
+ if self.exclude:
103
+ qs = qs.exclude(**filters)
104
+ else:
105
+ qs = qs.filter(**filters)
106
+ return qs
83
107
 
84
108
 
85
109
  class FinancialPerformanceDateRangeFilter(DateRangeFilter):
86
110
  def __init__(self, *args, **kwargs):
87
- super().__init__(performance_mode=True, shortcuts=financial_performance_shortcuts, *args, **kwargs)
111
+ super().__init__(*args, performance_mode=True, shortcuts=financial_performance_shortcuts, **kwargs)
88
112
 
89
113
 
90
114
  class DateTimeRangeFilter(DateRangeFilter):
91
115
  field_class = DateTimeRangeField
92
116
  initial_format = "%Y-%m-%dT%H:%M:%S%z"
117
+ filter_type = "datetimerange"
@@ -33,8 +33,8 @@ class ModelChoiceFilterMixin(WBCoreFilterMixin):
33
33
  "value": value_id,
34
34
  "label": str(queryset.get(id=value_id)),
35
35
  }
36
- except ObjectDoesNotExist:
37
- raise ParseError("Filter value invalid")
36
+ except ObjectDoesNotExist as e:
37
+ raise ParseError("Filter value invalid") from e
38
38
 
39
39
  def get_representation(self, request, name, view):
40
40
  representation, lookup_expr = super().get_representation(request, name, view)
@@ -1,5 +1,7 @@
1
1
  import logging
2
+ from collections import OrderedDict
2
3
  from contextlib import suppress
4
+ from copy import copy
3
5
 
4
6
  from django.contrib.postgres.fields import DateRangeField, DateTimeRangeField
5
7
  from django.core.exceptions import FieldError
@@ -28,8 +30,8 @@ def _is_number(field):
28
30
 
29
31
 
30
32
  class CustomFilterSetMetaClass(FilterSetMetaclass):
31
- def __new__(cls, name, bases, attrs):
32
- new_class = super().__new__(cls, name, bases, attrs)
33
+ def __new__(cls, *args, **kwargs):
34
+ new_class = super().__new__(cls, *args, **kwargs)
33
35
  if _meta := getattr(new_class, "Meta", None):
34
36
  for parent_field_name, child_fields in getattr(_meta, "flatten_fields", dict()).items():
35
37
  if remote_field := getattr(_meta.model._meta.get_field(parent_field_name), "remote_field", None):
@@ -65,6 +67,7 @@ class CustomFilterSetMetaClass(FilterSetMetaclass):
65
67
 
66
68
 
67
69
  class FilterSet(DjangoFilterSet, metaclass=CustomFilterSetMetaClass):
70
+ DEFAULT_EXCLUDE_FILTER_LOOKUP: str = "exclude"
68
71
  FILTER_DEFAULTS = {
69
72
  models.BooleanField: {"filter_class": fields.BooleanFilter},
70
73
  models.NullBooleanField: {"filter_class": fields.BooleanFilter},
@@ -168,7 +171,7 @@ class FilterSet(DjangoFilterSet, metaclass=CustomFilterSetMetaClass):
168
171
  for _, res in remote_filters:
169
172
  if res:
170
173
  for remote_filter_key, remote_filter in res.items():
171
- setattr(remote_filter, "column_field_name", remote_filter_key)
174
+ remote_filter.column_field_name = remote_filter_key
172
175
  self.filters[remote_filter_key] = remote_filter
173
176
 
174
177
  @classmethod
@@ -196,12 +199,12 @@ class FilterSet(DjangoFilterSet, metaclass=CustomFilterSetMetaClass):
196
199
 
197
200
  @classmethod
198
201
  def get_filters(cls):
199
- filters = super().get_filters()
202
+ filters = dict(super().get_filters())
200
203
  remote_filters = add_filters.send(sender=cls.filter_class_for_remote_filter())
201
204
  for _, res in remote_filters:
202
205
  if res:
203
206
  for remote_filter_key, remote_filter in res.items():
204
- setattr(remote_filter, "column_field_name", remote_filter_key)
207
+ remote_filter.column_field_name = remote_filter_key
205
208
  filters[remote_filter_key] = remote_filter
206
209
 
207
210
  for field, help_text in getattr(cls, "help_texts", {}).items():
@@ -211,7 +214,20 @@ class FilterSet(DjangoFilterSet, metaclass=CustomFilterSetMetaClass):
211
214
  for field, values in cls.get_dependency_map():
212
215
  for value in values:
213
216
  filters[field].depends_on.append({"field": value, "options": {}})
214
- return filters
217
+
218
+ excluding_fields = {}
219
+ for name, field in filters.items():
220
+ # if allow_exclude is true, we add a copy of the field with the parameter exclude=True
221
+ # (to use `exclude` queryset method instead of `filter`) and add this with the suffix __{cls.DEFAULT_EXCLUDE_FILTER_LOOKUP}
222
+ if field.allow_exclude:
223
+ excluding_field = copy(field)
224
+ excluding_field.exclude = True
225
+ excluding_field.excluded_filter = True
226
+ excluding_field.hidden = True
227
+ excluding_field.required = False
228
+ excluding_fields[f"{name}__{cls.DEFAULT_EXCLUDE_FILTER_LOOKUP}"] = excluding_field
229
+ filters.update(excluding_fields)
230
+ return OrderedDict(filters)
215
231
 
216
232
  def extract_required_field_labels(self):
217
233
  return [label for label, filter in self.base_filters.items() if getattr(filter, "required", False)]
wbcore/filters/mixins.py CHANGED
@@ -30,6 +30,10 @@ class WBCoreFilterMixin:
30
30
  "label_format",
31
31
  getattr(self, "default_label_format", "{{field_label}} {{operation_icon}} {{value_label}}"),
32
32
  )
33
+ self.allow_exclude = kwargs.pop(
34
+ "allow_exclude", kwargs.get("method") is None
35
+ ) # if False, we will not automatically add a similar filter "opposite" filter
36
+ self.excluded_filter = kwargs.pop("excluded_filter", False)
33
37
  self.lookup_icon = kwargs.pop("lookup_icon", None)
34
38
  self.lookup_label = kwargs.pop("lookup_label", None)
35
39
  self.depends_on = kwargs.pop("depends_on", [])
@@ -90,6 +94,7 @@ class WBCoreFilterMixin:
90
94
  "icon": get_lookup_icon(self.lookup_expr) if self.lookup_icon is None else self.lookup_icon,
91
95
  "key": name,
92
96
  "hidden": self.hidden,
97
+ "allow_exclude": self.allow_exclude,
93
98
  "input_properties": {
94
99
  "type": self.filter_type,
95
100
  },
@@ -101,7 +106,6 @@ class WBCoreFilterMixin:
101
106
  if initial is not None or self.allow_empty_initial:
102
107
  lookup_expr["input_properties"]["initial"] = initial
103
108
 
104
- if self.required:
105
- lookup_expr["input_properties"]["required"] = True
109
+ lookup_expr["input_properties"]["required"] = self.required
106
110
  representation["depends_on"] = self.depends_on
107
111
  return representation, lookup_expr
wbcore/forms.py CHANGED
@@ -3,7 +3,7 @@ from urllib.parse import unquote
3
3
 
4
4
  from django import forms
5
5
  from django.contrib.contenttypes.models import ContentType
6
- from django.core.exceptions import ValidationError
6
+ from django.core.exceptions import ObjectDoesNotExist, ValidationError
7
7
  from django.forms import modelformset_factory
8
8
  from django.forms.models import BaseModelFormSet
9
9
  from psycopg.types.range import DateRange, TimestamptzRange
@@ -39,10 +39,10 @@ def nonrelated_inlineformset_factory(
39
39
  """
40
40
  FormSet factory that sets an explicit queryset on new classes.
41
41
  """
42
- FormSet = modelformset_factory(model, formset=formset, **kwargs)
43
- FormSet.real_queryset = queryset
44
- FormSet.save_new_instance = save_new_instance
45
- return FormSet
42
+ form = modelformset_factory(model, formset=formset, **kwargs)
43
+ form.real_queryset = queryset
44
+ form.save_new_instance = save_new_instance
45
+ return form
46
46
 
47
47
 
48
48
  class ContentTypeMultiValueField(forms.fields.MultiValueField):
@@ -68,7 +68,7 @@ class ContentTypeMultiValueField(forms.fields.MultiValueField):
68
68
  try:
69
69
  content_type = ContentType.objects.get_for_id(content_type_id)
70
70
  return content_type.get_object_for_this_type(id=object_id)
71
- except (ContentType.DoesNotExist, content_type.model_class().DoesNotExist):
71
+ except ObjectDoesNotExist:
72
72
  return None
73
73
 
74
74
 
@@ -27,5 +27,5 @@ class FSMPreprocessor(Preprocessor):
27
27
 
28
28
 
29
29
  class FSMExtension(Extension):
30
- def extendMarkdown(self, md, md_globals):
30
+ def extendMarkdown(self, md, md_globals): # noqa
31
31
  md.preprocessors.register(FSMPreprocessor(), "wbcore-fsm", 100)