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.
- wbcore/admin.py +0 -1
- wbcore/configurations/configurations/apps.py +1 -0
- wbcore/contrib/agenda/locale/de/LC_MESSAGES/django.po +17 -18
- wbcore/contrib/agenda/locale/en/LC_MESSAGES/django.po +17 -17
- wbcore/contrib/agenda/locale/fr/LC_MESSAGES/django.po +17 -18
- wbcore/contrib/agenda/models/calendar_item.py +9 -0
- wbcore/contrib/authentication/authentication.py +1 -3
- wbcore/contrib/authentication/configurations.py +0 -1
- wbcore/contrib/authentication/locale/de/LC_MESSAGES/django.po +47 -48
- wbcore/contrib/authentication/locale/en/LC_MESSAGES/django.po +47 -47
- wbcore/contrib/authentication/locale/fr/LC_MESSAGES/django.po +47 -48
- wbcore/contrib/authentication/models/users.py +18 -0
- wbcore/contrib/color/admin.py +1 -0
- wbcore/contrib/color/factories.py +1 -0
- wbcore/contrib/content_type/__init__.py +0 -0
- wbcore/contrib/content_type/apps.py +5 -0
- wbcore/{filters/fields/content_type.py → contrib/content_type/filters.py} +18 -0
- wbcore/{content_type → contrib/content_type}/serializers.py +2 -2
- wbcore/contrib/content_type/urls.py +15 -0
- wbcore/contrib/directory/factories/contacts.py +1 -0
- wbcore/contrib/directory/locale/de/LC_MESSAGES/django.po +307 -271
- wbcore/contrib/directory/locale/en/LC_MESSAGES/django.po +305 -268
- wbcore/contrib/directory/locale/fr/LC_MESSAGES/django.po +305 -269
- wbcore/contrib/directory/migrations/0015_alter_emailcontact_address_and_more.py +47 -0
- wbcore/contrib/directory/models/contacts.py +15 -22
- wbcore/contrib/directory/models/entries.py +82 -2
- wbcore/contrib/directory/models/relationships.py +5 -9
- wbcore/contrib/directory/tests/test_models.py +5 -3
- wbcore/contrib/directory/viewsets/endpoints/__init__.py +2 -0
- wbcore/contrib/directory/viewsets/endpoints/entries.py +10 -0
- wbcore/contrib/directory/viewsets/entries.py +9 -2
- wbcore/contrib/documents/locale/de/LC_MESSAGES/django.po +26 -23
- wbcore/contrib/documents/locale/en/LC_MESSAGES/django.po +26 -22
- wbcore/contrib/documents/locale/fr/LC_MESSAGES/django.po +26 -23
- wbcore/contrib/documents/serializers/document_model_relationships.py +1 -1
- wbcore/contrib/documents/viewsets/documents.py +1 -1
- wbcore/contrib/guardian/__init__.py +0 -0
- wbcore/contrib/guardian/filters.py +1 -0
- wbcore/contrib/guardian/models/mixins.py +1 -0
- wbcore/contrib/guardian/tasks.py +1 -0
- wbcore/contrib/guardian/tests/test_model_mixins.py +1 -0
- wbcore/contrib/guardian/tests/test_tasks.py +1 -0
- wbcore/contrib/guardian/tests/test_utils.py +1 -0
- wbcore/contrib/guardian/tests/test_viewsets.py +1 -0
- wbcore/contrib/guardian/urls.py +1 -0
- wbcore/contrib/guardian/utils.py +1 -0
- wbcore/contrib/guardian/viewsets/configs/buttons.py +1 -0
- wbcore/contrib/guardian/viewsets/configs/endpoints.py +1 -0
- wbcore/contrib/guardian/viewsets/viewsets.py +1 -0
- wbcore/contrib/io/locale/de/LC_MESSAGES/django.po +28 -28
- wbcore/contrib/io/locale/en/LC_MESSAGES/django.po +28 -28
- wbcore/contrib/io/locale/fr/LC_MESSAGES/django.po +28 -28
- wbcore/contrib/notifications/backends/firebase/backends.py +2 -2
- wbcore/contrib/notifications/dispatch.py +1 -3
- wbcore/contrib/notifications/locale/de/LC_MESSAGES/django.po +2 -3
- wbcore/contrib/notifications/locale/en/LC_MESSAGES/django.po +2 -2
- wbcore/contrib/notifications/locale/fr/LC_MESSAGES/django.po +2 -3
- wbcore/contrib/notifications/models/notification_types.py +5 -7
- wbcore/contrib/notifications/models/notifications.py +2 -2
- wbcore/contrib/notifications/models/tokens.py +2 -2
- wbcore/contrib/tags/filters.py +1 -1
- wbcore/contrib/tags/serializers.py +2 -2
- wbcore/contrib/workflow/locale/de/LC_MESSAGES/django.po +75 -76
- wbcore/contrib/workflow/locale/en/LC_MESSAGES/django.po +75 -75
- wbcore/contrib/workflow/locale/fr/LC_MESSAGES/django.po +75 -76
- wbcore/contrib/workflow/serializers/process.py +4 -4
- wbcore/contrib/workflow/serializers/workflow.py +1 -1
- wbcore/filters/__init__.py +0 -1
- wbcore/filters/fields/__init__.py +0 -1
- wbcore/frontend_user_configuration.py +3 -2
- wbcore/locale/de/LC_MESSAGES/django.po +159 -129
- wbcore/locale/en/LC_MESSAGES/django.po +158 -129
- wbcore/locale/fr/LC_MESSAGES/django.po +159 -129
- wbcore/menus/menus.py +8 -5
- wbcore/metadata/configs/buttons/view_config.py +4 -1
- wbcore/metadata/configs/display/models.py +4 -5
- wbcore/metadata/configs/endpoints.py +7 -5
- wbcore/migrations/0015_delete_genericmodel.py +16 -0
- wbcore/models/base.py +0 -11
- wbcore/release_notes/models.py +2 -4
- wbcore/serializers/fields/fields.py +4 -2
- wbcore/serializers/fields/number.py +9 -0
- wbcore/shares/config.py +2 -2
- wbcore/signals/merge.py +1 -0
- wbcore/test/utils.py +3 -1
- wbcore/tests/test_permissions/test_backend.py +1 -3
- wbcore/tests/test_something.py +2 -2
- wbcore/urls.py +5 -10
- wbcore/utils/models.py +73 -4
- wbcore/utils/views.py +54 -53
- wbcore/viewsets/mixins.py +1 -1
- wbcore/viewsets/viewsets.py +1 -1
- {wbcore-1.59.15.dist-info → wbcore-1.60.0.dist-info}/METADATA +1 -1
- {wbcore-1.59.15.dist-info → wbcore-1.60.0.dist-info}/RECORD +99 -99
- wbcore/content_type/filters.py +0 -20
- wbcore/pandas/__init__.py +0 -53
- wbcore/pandas/fields.py +0 -25
- wbcore/pandas/filterset.py +0 -9
- wbcore/pandas/utils.py +0 -25
- wbcore/pandas/views.py +0 -9
- /wbcore/{content_type → contrib/color}/__init__.py +0 -0
- /wbcore/{content_type → contrib/content_type}/admin.py +0 -0
- /wbcore/{content_type → contrib/content_type}/utils.py +0 -0
- /wbcore/{content_type → contrib/content_type}/viewsets.py +0 -0
- {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
|
|
20
|
-
|
|
21
|
+
if request.user.has_perm(permission):
|
|
22
|
+
has_permission = True
|
|
23
|
+
break
|
|
21
24
|
|
|
22
25
|
if self.method:
|
|
23
|
-
|
|
26
|
+
has_permission &= self.method(request=request)
|
|
24
27
|
|
|
25
|
-
return
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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, "
|
|
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, "
|
|
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
|
-
|
|
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, "
|
|
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)
|
wbcore/release_notes/models.py
CHANGED
|
@@ -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=
|
|
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
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
|
|
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
|
|
wbcore/tests/test_something.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# from selenium import webdriver
|
|
4
4
|
# from channels.testing import ChannelsLiveServerTestCase
|
|
5
5
|
|
|
6
|
-
# from
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
157
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
"
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
wbcore/viewsets/viewsets.py
CHANGED