wbcore 1.59.15__py2.py3-none-any.whl → 1.60.0__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 (105) hide show
  1. wbcore/admin.py +0 -1
  2. wbcore/configurations/configurations/apps.py +1 -0
  3. wbcore/contrib/agenda/locale/de/LC_MESSAGES/django.po +17 -18
  4. wbcore/contrib/agenda/locale/en/LC_MESSAGES/django.po +17 -17
  5. wbcore/contrib/agenda/locale/fr/LC_MESSAGES/django.po +17 -18
  6. wbcore/contrib/agenda/models/calendar_item.py +9 -0
  7. wbcore/contrib/authentication/authentication.py +1 -3
  8. wbcore/contrib/authentication/configurations.py +0 -1
  9. wbcore/contrib/authentication/locale/de/LC_MESSAGES/django.po +47 -48
  10. wbcore/contrib/authentication/locale/en/LC_MESSAGES/django.po +47 -47
  11. wbcore/contrib/authentication/locale/fr/LC_MESSAGES/django.po +47 -48
  12. wbcore/contrib/authentication/models/users.py +18 -0
  13. wbcore/contrib/color/admin.py +1 -0
  14. wbcore/contrib/color/factories.py +1 -0
  15. wbcore/contrib/content_type/__init__.py +0 -0
  16. wbcore/contrib/content_type/apps.py +5 -0
  17. wbcore/{filters/fields/content_type.py → contrib/content_type/filters.py} +18 -0
  18. wbcore/{content_type → contrib/content_type}/serializers.py +2 -2
  19. wbcore/contrib/content_type/urls.py +15 -0
  20. wbcore/contrib/directory/factories/contacts.py +1 -0
  21. wbcore/contrib/directory/locale/de/LC_MESSAGES/django.po +307 -271
  22. wbcore/contrib/directory/locale/en/LC_MESSAGES/django.po +305 -268
  23. wbcore/contrib/directory/locale/fr/LC_MESSAGES/django.po +305 -269
  24. wbcore/contrib/directory/migrations/0015_alter_emailcontact_address_and_more.py +47 -0
  25. wbcore/contrib/directory/models/contacts.py +15 -22
  26. wbcore/contrib/directory/models/entries.py +82 -2
  27. wbcore/contrib/directory/models/relationships.py +5 -9
  28. wbcore/contrib/directory/tests/test_models.py +5 -3
  29. wbcore/contrib/directory/viewsets/endpoints/__init__.py +2 -0
  30. wbcore/contrib/directory/viewsets/endpoints/entries.py +10 -0
  31. wbcore/contrib/directory/viewsets/entries.py +9 -2
  32. wbcore/contrib/documents/locale/de/LC_MESSAGES/django.po +26 -23
  33. wbcore/contrib/documents/locale/en/LC_MESSAGES/django.po +26 -22
  34. wbcore/contrib/documents/locale/fr/LC_MESSAGES/django.po +26 -23
  35. wbcore/contrib/documents/serializers/document_model_relationships.py +1 -1
  36. wbcore/contrib/documents/viewsets/documents.py +1 -1
  37. wbcore/contrib/guardian/__init__.py +0 -0
  38. wbcore/contrib/guardian/filters.py +1 -0
  39. wbcore/contrib/guardian/models/mixins.py +1 -0
  40. wbcore/contrib/guardian/tasks.py +1 -0
  41. wbcore/contrib/guardian/tests/test_model_mixins.py +1 -0
  42. wbcore/contrib/guardian/tests/test_tasks.py +1 -0
  43. wbcore/contrib/guardian/tests/test_utils.py +1 -0
  44. wbcore/contrib/guardian/tests/test_viewsets.py +1 -0
  45. wbcore/contrib/guardian/urls.py +1 -0
  46. wbcore/contrib/guardian/utils.py +1 -0
  47. wbcore/contrib/guardian/viewsets/configs/buttons.py +1 -0
  48. wbcore/contrib/guardian/viewsets/configs/endpoints.py +1 -0
  49. wbcore/contrib/guardian/viewsets/viewsets.py +1 -0
  50. wbcore/contrib/io/locale/de/LC_MESSAGES/django.po +28 -28
  51. wbcore/contrib/io/locale/en/LC_MESSAGES/django.po +28 -28
  52. wbcore/contrib/io/locale/fr/LC_MESSAGES/django.po +28 -28
  53. wbcore/contrib/notifications/backends/firebase/backends.py +2 -2
  54. wbcore/contrib/notifications/dispatch.py +1 -3
  55. wbcore/contrib/notifications/locale/de/LC_MESSAGES/django.po +2 -3
  56. wbcore/contrib/notifications/locale/en/LC_MESSAGES/django.po +2 -2
  57. wbcore/contrib/notifications/locale/fr/LC_MESSAGES/django.po +2 -3
  58. wbcore/contrib/notifications/models/notification_types.py +5 -7
  59. wbcore/contrib/notifications/models/notifications.py +2 -2
  60. wbcore/contrib/notifications/models/tokens.py +2 -2
  61. wbcore/contrib/tags/filters.py +1 -1
  62. wbcore/contrib/tags/serializers.py +2 -2
  63. wbcore/contrib/workflow/locale/de/LC_MESSAGES/django.po +75 -76
  64. wbcore/contrib/workflow/locale/en/LC_MESSAGES/django.po +75 -75
  65. wbcore/contrib/workflow/locale/fr/LC_MESSAGES/django.po +75 -76
  66. wbcore/contrib/workflow/serializers/process.py +4 -4
  67. wbcore/contrib/workflow/serializers/workflow.py +1 -1
  68. wbcore/filters/__init__.py +0 -1
  69. wbcore/filters/fields/__init__.py +0 -1
  70. wbcore/frontend_user_configuration.py +3 -2
  71. wbcore/locale/de/LC_MESSAGES/django.po +159 -129
  72. wbcore/locale/en/LC_MESSAGES/django.po +158 -129
  73. wbcore/locale/fr/LC_MESSAGES/django.po +159 -129
  74. wbcore/menus/menus.py +8 -5
  75. wbcore/metadata/configs/buttons/view_config.py +4 -1
  76. wbcore/metadata/configs/display/models.py +4 -5
  77. wbcore/metadata/configs/endpoints.py +7 -5
  78. wbcore/migrations/0015_delete_genericmodel.py +16 -0
  79. wbcore/models/base.py +0 -11
  80. wbcore/release_notes/models.py +2 -4
  81. wbcore/serializers/fields/fields.py +4 -2
  82. wbcore/serializers/fields/number.py +9 -0
  83. wbcore/shares/config.py +2 -2
  84. wbcore/signals/merge.py +1 -0
  85. wbcore/test/utils.py +3 -1
  86. wbcore/tests/test_permissions/test_backend.py +1 -3
  87. wbcore/tests/test_something.py +2 -2
  88. wbcore/urls.py +5 -10
  89. wbcore/utils/models.py +73 -4
  90. wbcore/utils/views.py +54 -53
  91. wbcore/viewsets/mixins.py +1 -1
  92. wbcore/viewsets/viewsets.py +1 -1
  93. {wbcore-1.59.15.dist-info → wbcore-1.60.0.dist-info}/METADATA +1 -1
  94. {wbcore-1.59.15.dist-info → wbcore-1.60.0.dist-info}/RECORD +99 -99
  95. wbcore/content_type/filters.py +0 -20
  96. wbcore/pandas/__init__.py +0 -53
  97. wbcore/pandas/fields.py +0 -25
  98. wbcore/pandas/filterset.py +0 -9
  99. wbcore/pandas/utils.py +0 -25
  100. wbcore/pandas/views.py +0 -9
  101. /wbcore/{content_type → contrib/color}/__init__.py +0 -0
  102. /wbcore/{content_type → contrib/content_type}/admin.py +0 -0
  103. /wbcore/{content_type → contrib/content_type}/utils.py +0 -0
  104. /wbcore/{content_type → contrib/content_type}/viewsets.py +0 -0
  105. {wbcore-1.59.15.dist-info → wbcore-1.60.0.dist-info}/WHEEL +0 -0
wbcore/menus/menus.py CHANGED
@@ -10,19 +10,22 @@ from rest_framework.reverse import reverse
10
10
  class ItemPermission:
11
11
  permissions: List[str] = field(default_factory=list)
12
12
  method: Optional[Callable] = None
13
+ include_superuser: bool = True
13
14
 
14
15
  def has_permission(self, request: Request) -> bool:
15
- if request.user.is_superuser:
16
+ if self.include_superuser and request.user.is_superuser:
16
17
  return True
17
18
 
19
+ has_permission = False
18
20
  for permission in self.permissions:
19
- if not request.user.has_perm(permission):
20
- return False
21
+ if request.user.has_perm(permission):
22
+ has_permission = True
23
+ break
21
24
 
22
25
  if self.method:
23
- return self.method(request=request)
26
+ has_permission &= self.method(request=request)
24
27
 
25
- return True
28
+ return has_permission
26
29
 
27
30
 
28
31
  @dataclass
@@ -120,8 +120,11 @@ class ButtonViewConfig(WBCoreViewConfig):
120
120
  return set(self.CUSTOM_EXTRA_BUTTONS)
121
121
 
122
122
  def _get_custom_extra_buttons(self):
123
+ buttons = self.get_custom_extra_buttons()
124
+ if hasattr(self.view, "add_extra_buttons") and (extra_buttons := self.view.add_extra_buttons(self.request)):
125
+ buttons.update(extra_buttons)
123
126
  yield from self.serialize(
124
- self.get_custom_extra_buttons(),
127
+ buttons,
125
128
  add_extra_button.send(
126
129
  self.view.__class__, instance=self.instance, request=self.request, view=self.view, **self.view.kwargs
127
130
  ),
@@ -1,12 +1,11 @@
1
- from django.contrib.auth import get_user_model
2
1
  from django.db import models
3
2
 
3
+ from wbcore.contrib.authentication.models.users import User
4
+
4
5
 
5
6
  class Preset(models.Model):
6
7
  title = models.CharField(max_length=64)
7
- user = models.ForeignKey(
8
- to=get_user_model(), related_name="presets", on_delete=models.CASCADE, null=True, blank=True
9
- )
8
+ user = models.ForeignKey(to=User, related_name="presets", on_delete=models.CASCADE, null=True, blank=True)
10
9
  display_identifier = models.CharField(max_length=512)
11
10
  display = models.JSONField(null=True, blank=True)
12
11
 
@@ -15,7 +14,7 @@ class Preset(models.Model):
15
14
 
16
15
 
17
16
  class AppliedPreset(models.Model):
18
- user = models.ForeignKey(to=get_user_model(), related_name="applied_presets", on_delete=models.CASCADE)
17
+ user = models.ForeignKey(to=User, related_name="applied_presets", on_delete=models.CASCADE)
19
18
  display_identifier_path = models.CharField(max_length=1024)
20
19
  preset = models.ForeignKey(
21
20
  to=Preset, related_name="applied_presets", on_delete=models.SET_NULL, null=True, blank=True
@@ -71,10 +71,11 @@ class EndpointViewConfig(WBCoreViewConfig):
71
71
  return None
72
72
 
73
73
  def get_delete_endpoint(self, **kwargs):
74
- return self.get_endpoint()
74
+ if not hasattr(self.view, "ONLY_READ_ONLY_ENDPOINT"):
75
+ return self.get_endpoint()
75
76
 
76
77
  def _get_delete_endpoint(self, **kwargs):
77
- read_only = getattr(self.view, "READ_ONLY", False)
78
+ read_only = getattr(self.view, "ONLY_READ_ONLY_ENDPOINT", False)
78
79
  content_type = self.view.get_content_type()
79
80
 
80
81
  if content_type is None:
@@ -99,7 +100,7 @@ class EndpointViewConfig(WBCoreViewConfig):
99
100
  def _get_create_endpoint(self):
100
101
  from wbcore.viewsets import ViewSet
101
102
 
102
- read_only = getattr(self.view, "READ_ONLY", False)
103
+ read_only = getattr(self.view, "ONLY_READ_ONLY_ENDPOINT", False)
103
104
  if read_only:
104
105
  return None
105
106
 
@@ -118,10 +119,11 @@ class EndpointViewConfig(WBCoreViewConfig):
118
119
  return None
119
120
 
120
121
  def get_update_endpoint(self, **kwargs):
121
- return self.get_instance_endpoint()
122
+ if not hasattr(self.view, "ONLY_READ_ONLY_ENDPOINT"):
123
+ return self.get_instance_endpoint()
122
124
 
123
125
  def _get_update_endpoint(self):
124
- if (endpoint := self.get_update_endpoint()) and not getattr(self.view, "READ_ONLY", False):
126
+ if (endpoint := self.get_update_endpoint()) and not getattr(self.view, "ONLY_READ_ONLY_ENDPOINT", False):
125
127
  from wbcore.viewsets import ViewSet
126
128
 
127
129
  content_type = self.view.get_content_type()
@@ -0,0 +1,16 @@
1
+ # Generated by Django 5.2.9 on 2026-01-09 14:54
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('wbcore', '0014_biguserobjectpermission_system'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.DeleteModel(
14
+ name='GenericModel',
15
+ ),
16
+ ]
wbcore/models/base.py CHANGED
@@ -1,4 +1,3 @@
1
- from contextlib import suppress
2
1
  from typing import Any, List, Optional
3
2
 
4
3
  from celery import shared_task
@@ -172,13 +171,3 @@ class WBModel(models.Model):
172
171
  errors = super().check(**kwargs)
173
172
  errors.extend(cls._check_model_methods())
174
173
  return errors
175
-
176
-
177
- @shared_task(queue=Queue.DEFAULT.value)
178
- def merge_as_task(content_type_id: int, main_object_id: int, merged_object_id: int):
179
- content_type = ContentType.objects.get(id=content_type_id)
180
- with suppress(content_type.model_class().DoesNotExist):
181
- main_object = content_type.get_object_for_this_type(id=main_object_id)
182
- merged_object = content_type.get_object_for_this_type(id=merged_object_id)
183
- if hasattr(main_object, "merge") and callable(main_object.merge):
184
- main_object.merge(merged_object)
@@ -1,7 +1,7 @@
1
- from django.contrib.auth import get_user_model
2
1
  from django.db import models
3
2
  from django.utils.translation import gettext_lazy as _
4
3
 
4
+ from wbcore.contrib.authentication.models.users import User
5
5
  from wbcore.release_notes.utils import parse_release_note
6
6
 
7
7
 
@@ -23,9 +23,7 @@ class ReleaseNote(models.Model):
23
23
  module = models.CharField(max_length=255, help_text=_("The workbench module of the release"))
24
24
  summary = models.CharField(max_length=512, help_text=_("A brief summary of the release"))
25
25
  notes = models.TextField(default="", help_text=_("What's new? What's improved? What's fixed?"))
26
- read_by = models.ManyToManyField(
27
- to=get_user_model(), related_name="read_patch_notes", db_table="bridger_releasenote_read_by"
28
- )
26
+ read_by = models.ManyToManyField(to=User, related_name="read_patch_notes", db_table="bridger_releasenote_read_by")
29
27
 
30
28
  @classmethod
31
29
  def handle_from_markdown(cls, markdown_content: str) -> "ReleaseNote":
@@ -180,9 +180,11 @@ class SlugRelatedField(WBCoreSerializerFieldMixin, serializers.SlugRelatedField)
180
180
 
181
181
 
182
182
  class SerializerMethodField(WBCoreSerializerFieldMixin, serializers.SerializerMethodField):
183
- def __init__(self, method_name=None, field_class=StringRelatedField, **kwargs):
183
+ def __init__(self, method_name=None, field_class=StringRelatedField, percent=None, **kwargs):
184
184
  self.field_class = field_class
185
- self.initkwargs = kwargs
185
+ self.initkwargs = kwargs.copy()
186
+ if percent:
187
+ self.initkwargs["percent"] = percent
186
188
  super().__init__(method_name, **kwargs)
187
189
 
188
190
  def get_representation(self, request, field_name):
@@ -86,6 +86,15 @@ class DecimalRangeField(RangeMixin, WBCoreSerializerFieldMixin, serializers.Deci
86
86
  field_type = WBCoreType.NUMBERRANGE.value
87
87
  internal_field = NumericRange
88
88
 
89
+ def to_representation(self, instance):
90
+ res = list(super().to_representation(instance))
91
+ # ensure empty value shows as None
92
+ if not res[0]:
93
+ res[0] = "-Infinity"
94
+ if not res[1]:
95
+ res[1] = "Infinity"
96
+ return tuple(res)
97
+
89
98
  def __init__(self, max_digits=None, decimal_places=None, **kwargs):
90
99
  if not max_digits:
91
100
  max_digits = 3
wbcore/shares/config.py CHANGED
@@ -1,9 +1,9 @@
1
- from django.contrib.auth import get_user_model
2
1
  from django.utils.translation import gettext_lazy as _
3
2
  from rest_framework.reverse import reverse
4
3
 
5
4
  from wbcore import serializers as wb_serializers
6
5
  from wbcore import shares
6
+ from wbcore.contrib.authentication.models.users import User
7
7
  from wbcore.contrib.authentication.serializers import UserRepresentationSerializer
8
8
  from wbcore.contrib.icons import WBIcon
9
9
  from wbcore.metadata.configs.buttons import ActionButton, ButtonConfig
@@ -35,7 +35,7 @@ class DefaultShareSerializer(wb_serializers.Serializer):
35
35
  )
36
36
  share_recipients = wb_serializers.PrimaryKeyRelatedField(
37
37
  many=True,
38
- queryset=get_user_model().objects.all(),
38
+ queryset=User.objects.all(),
39
39
  label=_("Recipient"),
40
40
  required=False,
41
41
  depends_on=[{"field": "share", "options": {}}],
wbcore/signals/merge.py CHANGED
@@ -2,3 +2,4 @@ from django.db.models.signals import ModelSignal
2
2
 
3
3
  # Signal fired before merged/obsolete object are deleted during a merge phase
4
4
  pre_merge = ModelSignal(use_caching=False)
5
+ post_merge = ModelSignal(use_caching=False)
wbcore/test/utils.py CHANGED
@@ -104,6 +104,8 @@ def get_data_from_factory(instance, viewset, delete=False, update=False, superus
104
104
  data[key] = document.file.open(mode='rb')
105
105
  """
106
106
  pass # data[key] = open(value.replace("http://testserver/",""), 'rb')
107
+ elif isinstance(value, tuple):
108
+ data[key] = f"{value[0]},{value[1]}"
107
109
  else:
108
110
  data[key] = value
109
111
  # Related objects with cascading on_delete will be deleted. we create a new related object and override the id of the deleted object
@@ -214,7 +216,7 @@ def get_factory_custom_user():
214
216
 
215
217
  def format_number(number, is_pourcent=False, decimal=2):
216
218
  number = number if number else 0
217
- return f'{number:,.{decimal}{"%" if is_pourcent else "f"}}'
219
+ return f"{number:,.{decimal}{'%' if is_pourcent else 'f'}}"
218
220
 
219
221
 
220
222
  # https://stackoverflow.com/questions/11875770/how-to-overcome-datetime-datetime-not-json-serializable?page=1&tab=votes#tab-top
@@ -1,12 +1,10 @@
1
1
  import pytest
2
- from django.contrib.auth import get_user_model
3
2
  from django.contrib.auth.models import Permission
4
3
  from django.contrib.contenttypes.models import ContentType
5
4
  from faker import Faker
5
+ from wbcore.contrib.authentication.models.users import User
6
6
  from wbcore.permissions.shortcuts import get_internal_users
7
7
 
8
- User = get_user_model()
9
-
10
8
  fake = Faker()
11
9
 
12
10
 
@@ -3,7 +3,7 @@
3
3
  # from selenium import webdriver
4
4
  # from channels.testing import ChannelsLiveServerTestCase
5
5
 
6
- # from django.contrib.auth import get_user_model, authenticate
6
+ # from wbcore.contrib.authentication.models.users import User, authenticate
7
7
  # from .selenium.utils import menu_items_for_user
8
8
 
9
9
  # def login(url, username, password):
@@ -22,7 +22,7 @@
22
22
 
23
23
  # @pytest.mark.parametrize
24
24
  # def test_something1(self):
25
- # user = get_user_model().objects.create_superuser(
25
+ # user = User.objects.create_superuser(
26
26
  # username="root",
27
27
  # email="a@a.de",
28
28
  # password="root"
wbcore/urls.py CHANGED
@@ -6,10 +6,6 @@ from wbcore.contrib.dynamic_preferences.viewsets import UserPreferencesViewSet
6
6
  from wbcore.shares.views import ShareAPIView
7
7
 
8
8
  from .configs.views import ConfigAPIView
9
- from .content_type.viewsets import (
10
- ContentTypeRepresentationViewSet,
11
- DynamicObjectIDRepresentationViewSet,
12
- )
13
9
  from .crontab.viewsets import CrontabScheduleRepresentationViewSet
14
10
  from .frontend_user_configuration import FrontendUserConfigurationModelViewSet
15
11
  from .markdown.views import (
@@ -44,12 +40,7 @@ router.register(r"revision", RevisionModelViewSet, basename="revision")
44
40
  router.register(r"revisionrepresentation", RevisionRepresentationViewSet, basename="revisionrepresentation")
45
41
  router.register(r"versionrepresentation", VersionRepresentationViewSet, basename="versionrepresentation")
46
42
  router.register(r"releasenote", ReleaseNoteReadOnlyModelViewSet, basename="releasenote")
47
- router.register(r"contenttyperepresentation", ContentTypeRepresentationViewSet, basename="contenttyperepresentation")
48
- router.register(
49
- r"dynamiccontenttyperepresentation",
50
- DynamicObjectIDRepresentationViewSet,
51
- basename="dynamiccontenttyperepresentation",
52
- )
43
+
53
44
  router.register(
54
45
  r"crontabschedulerepresentation", CrontabScheduleRepresentationViewSet, basename="crontabschedulerepresentation"
55
46
  )
@@ -73,6 +64,10 @@ urlpatterns = [
73
64
  "authentication/",
74
65
  include(("wbcore.contrib.authentication.urls", "wbcore.contrib.authentication"), namespace="authentication"),
75
66
  ),
67
+ path(
68
+ "content_type/",
69
+ include(("wbcore.contrib.content_type.urls", "wbcore.contrib.content_type"), namespace="content_type"),
70
+ ),
76
71
  path(
77
72
  "notifications/",
78
73
  include(("wbcore.contrib.notifications.urls", "wbcore.contrib.notifications"), namespace="notifications"),
wbcore/utils/models.py CHANGED
@@ -1,8 +1,10 @@
1
1
  from contextlib import suppress
2
2
  from typing import Self
3
3
 
4
+ from celery import shared_task
5
+ from django.contrib.contenttypes.models import ContentType
4
6
  from django.core.exceptions import FieldDoesNotExist, FieldError
5
- from django.db import models
7
+ from django.db import models, transaction
6
8
  from django.db.models.signals import post_delete, pre_delete
7
9
  from django.utils import timezone
8
10
  from django.utils.translation import gettext_lazy as _
@@ -13,8 +15,10 @@ from wbcore.contrib.color.enums import WBColor
13
15
  from wbcore.contrib.color.fields import ColorField
14
16
  from wbcore.contrib.icons import WBIcon
15
17
  from wbcore.contrib.icons.models import IconField
16
- from wbcore.signals import post_clone
18
+ from wbcore.signals import post_clone, pre_merge
19
+ from wbcore.signals.merge import post_merge
17
20
  from wbcore.utils.enum import ChoiceEnum
21
+ from wbcore.workers import Queue
18
22
 
19
23
 
20
24
  def get_and_update_or_create(model, filter_params, defaults):
@@ -153,8 +157,12 @@ class PrimaryMixin(models.Model):
153
157
  if self.can_update_primary_field:
154
158
  related_qs = self.get_related_queryset()
155
159
  if self.primary:
156
- related_qs = related_qs.exclude(id=self.id) # if self id is None, it will not exclude anything
157
- related_qs.update(primary=False)
160
+ related_qs = related_qs.exclude(id=self.id).filter(
161
+ primary=True
162
+ ) # if self id is None, it will not exclude anything
163
+ if related_qs.exists():
164
+ self.previous_primary_entity = related_qs.first()
165
+ related_qs.update(primary=False)
158
166
  elif not related_qs.filter(primary=True).exists():
159
167
  self.primary = True
160
168
  super().save(*args, **kwargs)
@@ -291,3 +299,64 @@ class CloneMixin(models.Model):
291
299
 
292
300
  class Meta:
293
301
  abstract = True
302
+
303
+
304
+ class MergeError(ValueError):
305
+ pass
306
+
307
+
308
+ class MergeMixin(models.Model):
309
+ """
310
+ Abstract Django model mixin that provides a standardized interface for merging
311
+ two model instances of the same type, typically used to consolidate duplicate
312
+ records (e.g., merging two Instruments, Clients, or Accounts).
313
+ """
314
+
315
+ @property
316
+ def is_mergeable(self) -> bool:
317
+ return True
318
+
319
+ def get_merge_senders_kwargs(self, merged_object):
320
+ # Default implementation: return tuple of (sender, main, merged) for signal dispatch.
321
+ # Subclasses can override to yield multiple entries if related models require updates.
322
+ # ideal for multi-inheritance
323
+ yield (self.__class__, merged_object, self)
324
+
325
+ def _merge(self, merged_object, **kwargs):
326
+ # Must be implemented by subclasses. It should define the data
327
+ # or relationship consolidation logic between self and merged_object.
328
+ # Example: transfer related foreign key references, update counters, etc.
329
+ raise NotImplementedError()
330
+
331
+ def merge(self, merged_object, dispatch: bool = True, **kwargs):
332
+ with transaction.atomic(): # We want this to either succeed fully or fail
333
+ if dispatch:
334
+ for sender, casted_merged_object, casted_main_object in self.get_merge_senders_kwargs(merged_object):
335
+ pre_merge.send(
336
+ sender=sender, merged_object=casted_merged_object, main_object=casted_main_object
337
+ ) # default signal dispatch for the Instrument class
338
+ # We refresh the reference in case the underlying signal receivers modify these objects
339
+ self.refresh_from_db()
340
+ merged_object.refresh_from_db()
341
+ self._merge(merged_object, **kwargs)
342
+ # We delete finally the merged object. All unlikage should have been done in the signal receivers function
343
+ if isinstance(merged_object, DeleteToDisableMixin):
344
+ merged_object.delete(no_deletion=False)
345
+ else:
346
+ merged_object.delete()
347
+
348
+ self.save()
349
+ post_merge.send(sender=self.__class__, merged_object=merged_object, main_object=self)
350
+
351
+ class Meta:
352
+ abstract = True
353
+
354
+
355
+ @shared_task(queue=Queue.DEFAULT.value)
356
+ def merge_as_task(content_type_id: int, main_object_id: int, merged_object_id: int):
357
+ content_type = ContentType.objects.get(id=content_type_id)
358
+ with suppress(content_type.model_class().DoesNotExist):
359
+ main_object = content_type.get_object_for_this_type(id=main_object_id)
360
+ merged_object = content_type.get_object_for_this_type(id=merged_object_id)
361
+ if hasattr(main_object, "merge") and callable(main_object.merge):
362
+ main_object.merge(merged_object)
wbcore/utils/views.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import importlib
2
2
  import re
3
+ from contextlib import suppress
3
4
  from functools import cached_property
4
5
 
5
6
  from django.conf import settings
@@ -23,8 +24,9 @@ from wbcore.metadata.configs.display.instance_display import Display
23
24
  from wbcore.metadata.configs.display.instance_display.shortcuts import (
24
25
  create_simple_display,
25
26
  )
26
- from wbcore.models.base import merge_as_task
27
27
  from wbcore.signals.instance_buttons import add_extra_button
28
+ from wbcore.utils.models import MergeMixin as ModelMergeMixin
29
+ from wbcore.utils.models import merge_as_task
28
30
 
29
31
 
30
32
  def parse_query_parameters_list(list_str):
@@ -134,13 +136,8 @@ class MergeMixin:
134
136
  def model(self):
135
137
  return self.queryset.model
136
138
 
137
- @cached_property
138
- def has_user_merge_permission(self) -> bool:
139
- return getattr(self, "MERGE_PERMISSION", f"administrate_{self.model._meta.model_name}")
140
-
141
- @cached_property
142
- def can_merge(self):
143
- return hasattr(self.queryset.model, "merge") and self.has_user_merge_permission
139
+ def has_merge_permission(self, user) -> bool:
140
+ return user.has_perm(f"administrate_{self.model._meta.model_name}")
144
141
 
145
142
  def get_merged_object_representation_serializer(self):
146
143
  """
@@ -157,51 +154,55 @@ class MergeMixin:
157
154
  """
158
155
  return dict()
159
156
 
160
- @action(methods=["PATCH"], detail=True)
161
- def merge(self, request, pk=None):
162
- instance = get_object_or_404(self.queryset.model, pk=pk)
163
- merged_instance = get_object_or_404(self.queryset.model, pk=request.POST.get("merged_object", None))
164
- try:
165
- content_type = ContentType.objects.get_for_model(self.model)
166
- except ContentType.DoesNotExist:
167
- content_type = None
168
- if content_type and self.has_user_merge_permission and hasattr(instance, "merge"):
169
- merge_as_task.delay(content_type.id, instance.id, merged_instance.id)
170
- return Response({"status": "Merged with success"})
171
- return Response({}, status=400)
172
-
157
+ def add_extra_buttons(self, request):
158
+ if self.has_merge_permission(request.user) and (pk := self.kwargs.get("pk")):
159
+ object = self.get_object()
160
+ if (
161
+ isinstance(object, ModelMergeMixin)
162
+ and object.is_mergeable
163
+ and (endpoint_basename := object.get_endpoint_basename())
164
+ ):
165
+ endpoint = reverse(f"{endpoint_basename}-merge", args=[pk], request=request)
173
166
 
174
- @receiver(add_extra_button)
175
- def add_merge_extra_button(sender, instance, request, view, pk=None, **kwargs):
176
- if instance and pk and issubclass(view.__class__, CloneMixin) and getattr(view, "can_merge", False):
177
- object = view.get_object()
178
- if getattr(object, "is_mergeable", True) and (endpoint_basename := object.get_endpoint_basename()):
179
- endpoint = reverse(f"{endpoint_basename}-merge", args=[pk], request=request)
180
- identifier = getattr(view, "IDENTIFIER", "{0.app_label}:{0.model}".format(view.get_content_type()))
167
+ class MergeSerializer(wb_serializer.ModelSerializer):
168
+ merged_object = wb_serializer.PrimaryKeyRelatedField(
169
+ queryset=self.model.objects.all(), label="Merged with"
170
+ )
171
+ _merged_object = self.get_merged_object_representation_serializer()(
172
+ source="merged_object",
173
+ filter_params=self.get_merged_object_representation_serializer_filter_params(),
174
+ )
181
175
 
182
- class MergeSerializer(wb_serializer.ModelSerializer):
183
- merged_object = wb_serializer.PrimaryKeyRelatedField(queryset=view.model.objects.all())
184
- _merged_object = view.get_merged_object_representation_serializer()(
185
- source="merged_object",
186
- filter_params=view.get_merged_object_representation_serializer_filter_params(),
187
- )
188
-
189
- class Meta:
190
- model = view.model
191
- fields = [
192
- "id",
193
- "merged_object",
194
- "_merged_object",
195
- ]
176
+ class Meta:
177
+ model = self.model
178
+ fields = [
179
+ "id",
180
+ "merged_object",
181
+ "_merged_object",
182
+ ]
183
+
184
+ return {
185
+ bt.ActionButton(
186
+ method=RequestType.PATCH,
187
+ endpoint=endpoint,
188
+ action_label=_("Merge"),
189
+ title=_("Merge"),
190
+ label=_("Merge"),
191
+ icon=WBIcon.MERGE.icon,
192
+ serializer=MergeSerializer,
193
+ instance_display=create_simple_display([["merged_object"]]),
194
+ ),
195
+ }
196
196
 
197
- return bt.ActionButton(
198
- method=RequestType.PATCH,
199
- identifiers=(identifier,),
200
- endpoint=endpoint,
201
- action_label=_("Merge"),
202
- title=_("Merge"),
203
- label=_("Merge"),
204
- icon=WBIcon.MERGE.icon,
205
- serializer=MergeSerializer,
206
- instance_display=create_simple_display([["merged_object"]]),
207
- )
197
+ @action(methods=["PATCH"], detail=True)
198
+ def merge(self, request, pk=None):
199
+ if self.has_merge_permission(request.user):
200
+ if pk and hasattr(self.model, "merge"):
201
+ instance = get_object_or_404(self.queryset.model, pk=pk)
202
+ merged_instance = get_object_or_404(self.queryset.model, pk=request.POST.get("merged_object", None))
203
+ with suppress(ContentType.DoesNotExist):
204
+ content_type = ContentType.objects.get_for_model(self.model)
205
+ merge_as_task.delay(content_type.id, instance.id, merged_instance.id)
206
+ return Response({"status": "Merged ongoing"})
207
+ return Response({}, status=400)
208
+ return Response({"_detail": "User does not have the permission to merge"}, status=status.HTTP_403_FORBIDDEN)
wbcore/viewsets/mixins.py CHANGED
@@ -265,7 +265,7 @@ class OrderableMixin:
265
265
  order = request.data.get("order")
266
266
  if order is None:
267
267
  return Response("No order received", status=HTTP_400_BAD_REQUEST)
268
- instance.to(order)
268
+ instance.to(int(order))
269
269
  return Response("Reordering Successful", status=HTTP_200_OK)
270
270
 
271
271
 
@@ -60,7 +60,7 @@ class ReadOnlyModelViewSet(
60
60
  GenericViewSet,
61
61
  ):
62
62
  pagination_class = LimitOffsetPagination
63
- READ_ONLY = True
63
+ ONLY_READ_ONLY_ENDPOINT = True
64
64
 
65
65
 
66
66
  class ModelViewSet(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wbcore
3
- Version: 1.59.15
3
+ Version: 1.60.0
4
4
  Author-email: Christopher Wittlinger <c.wittlinger@stainly.com>
5
5
  Requires-Dist: celery[redis]==5.*
6
6
  Requires-Dist: croniter==2.*