wbcommission 2.2.1__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.

Potentially problematic release.


This version of wbcommission might be problematic. Click here for more details.

Files changed (76) hide show
  1. wbcommission/__init__.py +1 -0
  2. wbcommission/admin/__init__.py +4 -0
  3. wbcommission/admin/accounts.py +22 -0
  4. wbcommission/admin/commission.py +85 -0
  5. wbcommission/admin/rebate.py +7 -0
  6. wbcommission/analytics/__init__.py +0 -0
  7. wbcommission/analytics/marginality.py +181 -0
  8. wbcommission/apps.py +5 -0
  9. wbcommission/dynamic_preferences_registry.py +0 -0
  10. wbcommission/factories/__init__.py +9 -0
  11. wbcommission/factories/commission.py +100 -0
  12. wbcommission/factories/rebate.py +16 -0
  13. wbcommission/filters/__init__.py +7 -0
  14. wbcommission/filters/rebate.py +187 -0
  15. wbcommission/filters/signals.py +44 -0
  16. wbcommission/generators/__init__.py +2 -0
  17. wbcommission/generators/rebate_generator.py +93 -0
  18. wbcommission/migrations/0001_initial.py +299 -0
  19. wbcommission/migrations/0002_commissionrule_remove_accountcustomer_account_and_more.py +395 -0
  20. wbcommission/migrations/0003_alter_commission_account.py +24 -0
  21. wbcommission/migrations/0004_rebate_audit_log.py +19 -0
  22. wbcommission/migrations/0005_alter_rebate_audit_log.py +20 -0
  23. wbcommission/migrations/0006_commissionrule_consider_zero_percent_for_exclusion.py +21 -0
  24. wbcommission/migrations/0007_remove_commission_unique_crm_recipient_account_and_more.py +50 -0
  25. wbcommission/migrations/0008_alter_commission_options_alter_commission_order.py +26 -0
  26. wbcommission/migrations/__init__.py +0 -0
  27. wbcommission/models/__init__.py +9 -0
  28. wbcommission/models/account_service.py +217 -0
  29. wbcommission/models/commission.py +679 -0
  30. wbcommission/models/rebate.py +319 -0
  31. wbcommission/models/signals.py +45 -0
  32. wbcommission/permissions.py +6 -0
  33. wbcommission/reports/__init__.py +0 -0
  34. wbcommission/reports/audit_report.py +51 -0
  35. wbcommission/reports/customer_report.py +299 -0
  36. wbcommission/reports/utils.py +30 -0
  37. wbcommission/serializers/__init__.py +3 -0
  38. wbcommission/serializers/commissions.py +26 -0
  39. wbcommission/serializers/rebate.py +87 -0
  40. wbcommission/serializers/signals.py +27 -0
  41. wbcommission/tests/__init__.py +0 -0
  42. wbcommission/tests/analytics/__init__.py +0 -0
  43. wbcommission/tests/analytics/test_marginality.py +253 -0
  44. wbcommission/tests/conftest.py +89 -0
  45. wbcommission/tests/models/__init__.py +0 -0
  46. wbcommission/tests/models/mixins.py +22 -0
  47. wbcommission/tests/models/test_account_service.py +293 -0
  48. wbcommission/tests/models/test_commission.py +587 -0
  49. wbcommission/tests/models/test_rebate.py +136 -0
  50. wbcommission/tests/signals.py +0 -0
  51. wbcommission/tests/test_permissions.py +66 -0
  52. wbcommission/tests/viewsets/__init__.py +0 -0
  53. wbcommission/tests/viewsets/test_rebate.py +76 -0
  54. wbcommission/urls.py +42 -0
  55. wbcommission/viewsets/__init__.py +7 -0
  56. wbcommission/viewsets/buttons/__init__.py +2 -0
  57. wbcommission/viewsets/buttons/rebate.py +46 -0
  58. wbcommission/viewsets/buttons/signals.py +53 -0
  59. wbcommission/viewsets/commissions.py +21 -0
  60. wbcommission/viewsets/display/__init__.py +5 -0
  61. wbcommission/viewsets/display/commissions.py +21 -0
  62. wbcommission/viewsets/display/rebate.py +117 -0
  63. wbcommission/viewsets/endpoints/__init__.py +4 -0
  64. wbcommission/viewsets/endpoints/commissions.py +0 -0
  65. wbcommission/viewsets/endpoints/rebate.py +21 -0
  66. wbcommission/viewsets/menu/__init__.py +1 -0
  67. wbcommission/viewsets/menu/commissions.py +0 -0
  68. wbcommission/viewsets/menu/rebate.py +13 -0
  69. wbcommission/viewsets/mixins.py +39 -0
  70. wbcommission/viewsets/rebate.py +481 -0
  71. wbcommission/viewsets/titles/__init__.py +1 -0
  72. wbcommission/viewsets/titles/commissions.py +0 -0
  73. wbcommission/viewsets/titles/rebate.py +11 -0
  74. wbcommission-2.2.1.dist-info/METADATA +11 -0
  75. wbcommission-2.2.1.dist-info/RECORD +76 -0
  76. wbcommission-2.2.1.dist-info/WHEEL +5 -0
@@ -0,0 +1,319 @@
1
+ from datetime import date as date_lib
2
+ from decimal import Decimal
3
+ from typing import Any, Optional
4
+
5
+ from celery import shared_task
6
+ from django.apps import apps
7
+ from django.core.serializers.json import DjangoJSONEncoder
8
+ from django.db import models
9
+ from django.db.models import Exists, OuterRef, QuerySet
10
+ from django.dispatch import receiver
11
+ from wbcore.contrib.authentication.models import User
12
+ from wbcore.contrib.notifications.dispatch import send_notification
13
+ from wbcore.signals import pre_merge
14
+ from wbcore.utils.enum import ChoiceEnum
15
+ from wbcrm.models.accounts import Account
16
+
17
+ from .commission import Commission, CommissionType
18
+
19
+
20
+ class BookingEntryCalculatedValueMixin:
21
+ @classmethod
22
+ def get_accounting_sum(cls, related_data: dict[str, str], queryset: QuerySet) -> float:
23
+ raise NotImplementedError
24
+
25
+ def update_calculated_value_of_booking_entry(self):
26
+ raise NotImplementedError
27
+
28
+
29
+ class RebateGroupbyChoice(ChoiceEnum):
30
+ ROOT_ACCOUNT = "Root Account"
31
+ ACCOUNT = "Account"
32
+ ROOT_ACCOUNT_OWNER = "Root Account Owner"
33
+ ACCOUNT_OWNER = "Account Owner"
34
+ PRODUCT = "Product"
35
+ PRODUCT_GROUP = "ProductGroup"
36
+ CLASSIFICATION = "Classification"
37
+ RECIPIENT = "Recipient"
38
+
39
+ @classmethod
40
+ @property
41
+ def map(cls) -> dict[str, dict[str, Any]]:
42
+ """
43
+ Field map used in the groupby filter in the rebate table view
44
+ """
45
+ return {
46
+ "ROOT_ACCOUNT": {
47
+ "pk": "root_account",
48
+ "title_key": "root_account_repr",
49
+ "search_fields": ["root_account_repr"],
50
+ },
51
+ "ACCOUNT": {
52
+ "pk": "account",
53
+ "title_key": "account__computed_str",
54
+ "search_fields": ["account__computed_str"],
55
+ },
56
+ "ROOT_ACCOUNT_OWNER": {
57
+ "pk": "root_account_owner",
58
+ "title_key": "root_account_owner_repr",
59
+ "search_fields": ["root_account_owner_repr"],
60
+ },
61
+ "ACCOUNT_OWNER": {
62
+ "pk": "account__owner",
63
+ "title_key": "account__owner__computed_str",
64
+ "search_fields": ["account__owner__computed_str"],
65
+ },
66
+ "PRODUCT": {
67
+ "pk": "product",
68
+ "title_key": "product__computed_str",
69
+ "search_fields": ["product__computed_str"],
70
+ },
71
+ "PRODUCT_GROUP": {
72
+ "pk": "product__parent",
73
+ "title_key": "product__parent__name",
74
+ "search_fields": ["product__parent__name"],
75
+ },
76
+ "CLASSIFICATION": {
77
+ "pk": "classification_id",
78
+ "title_key": "classification_title",
79
+ "search_fields": ["classification_title"],
80
+ },
81
+ "RECIPIENT": {
82
+ "pk": "recipient__id",
83
+ "title_key": "recipient__computed_str",
84
+ "search_fields": ["recipient__computed_str"],
85
+ },
86
+ }
87
+
88
+
89
+ class RebateDefaultQueryset(QuerySet):
90
+ def filter_for_user(self, user: User, validity_date: date_lib | None = None) -> QuerySet:
91
+ """
92
+ Protect the chained queryset and filter the rebates that this user cannot see based on the following rules:
93
+
94
+ * not-hidden commission: Ever user with a direct valid account role on the commission's account
95
+ * hidden: only user with a direct commission role on that commission line
96
+ * in any case, all user with direct commission role
97
+ """
98
+ if not validity_date:
99
+ validity_date = date_lib.today()
100
+ if user.has_perm("wbcommission.administrate_commission"):
101
+ return self
102
+ allowed_commission_lines = Commission.objects.filter_for_user(user, validity_date=validity_date)
103
+ return self.annotate(
104
+ can_see_commission=Exists(allowed_commission_lines.filter(id=OuterRef("commission"))),
105
+ ).filter(can_see_commission=True)
106
+
107
+
108
+ class RebateManager(models.Manager):
109
+ def get_queryset(self) -> RebateDefaultQueryset:
110
+ return RebateDefaultQueryset(self.model)
111
+
112
+ def filter_for_user(self, user: User, validity_date: date_lib | None = None) -> QuerySet:
113
+ return self.get_queryset().filter_for_user(user, validity_date=validity_date)
114
+
115
+
116
+ class Rebate(BookingEntryCalculatedValueMixin, models.Model):
117
+ """The fees that get rebated to a recipient"""
118
+
119
+ int: Optional[int]
120
+ date = models.DateField(verbose_name="Date")
121
+ account = models.ForeignKey(
122
+ "wbcrm.Account",
123
+ related_name="rebates",
124
+ on_delete=models.CASCADE,
125
+ verbose_name="Account",
126
+ limit_choices_to=models.Q(("is_terminal_account", True)),
127
+ )
128
+ product = models.ForeignKey(
129
+ "wbportfolio.Product", related_name="rebates", on_delete=models.CASCADE, verbose_name="Product"
130
+ )
131
+ recipient = models.ForeignKey(
132
+ "directory.Entry", related_name="recipient_rebates", on_delete=models.PROTECT, verbose_name="Recipient"
133
+ )
134
+ commission_type = models.ForeignKey(
135
+ "wbcommission.CommissionType",
136
+ on_delete=models.PROTECT,
137
+ related_name="rebates",
138
+ verbose_name="Commission Type",
139
+ )
140
+
141
+ commission = models.ForeignKey(
142
+ "wbcommission.Commission",
143
+ related_name="rebates",
144
+ on_delete=models.PROTECT,
145
+ verbose_name="Commission Line",
146
+ )
147
+
148
+ value = models.DecimalField(max_digits=16, decimal_places=4, default=Decimal(0.0), verbose_name="Value")
149
+
150
+ audit_log = models.JSONField(default=dict, verbose_name="Audit Log", encoder=DjangoJSONEncoder)
151
+ objects = RebateManager()
152
+
153
+ class Meta:
154
+ verbose_name = "Rebate"
155
+ verbose_name_plural = "Rebates"
156
+ unique_together = ("date", "recipient", "account", "product", "commission_type")
157
+ index_together = [
158
+ # ("date", "recipient", "product"),
159
+ ("commission_type", "date", "recipient", "product", "account"),
160
+ # ("date", "recipient", "product", "commission"),
161
+ ]
162
+ notification_types = [
163
+ (
164
+ "wbcommission.rebate.computation_done",
165
+ "Rebate Computation Done",
166
+ "Sends a notification to notify rebate computation requester that the calculation is done",
167
+ True,
168
+ True,
169
+ False,
170
+ ),
171
+ ]
172
+
173
+ def __str__(self) -> str:
174
+ return repr(self)
175
+
176
+ def __repr__(self) -> str:
177
+ return f"{self.date:%d.%m.%Y}: {self.recipient.computed_str} {self.account.title}"
178
+
179
+ @classmethod
180
+ def manage_rebate(
181
+ cls,
182
+ main_account: Account,
183
+ prune_existing: bool = False,
184
+ start_date: date_lib | None = None,
185
+ only_content_object_ids: list[int] | None = None,
186
+ **filter_kwargs,
187
+ ):
188
+ """
189
+ Utility method to generate rebate for all commission types for the given root account.
190
+
191
+ This method get the rebate information from the commission type's rebate manager and decide wether to create a new rebate or update an existing one
192
+
193
+ Args:
194
+ main_account: root account to generate rebates from
195
+ prune_existing: If true, will delete all existing rebates for that account and its children. Default to False. Note: For optimization, the rebate manager will yield only valid rebate information and thus, existing obselete rebate might never be overidden with a zero value.
196
+ **filter_kwargs: these key-word arguments are passed down the rebate manager for as keyword argument for the iterator function
197
+
198
+ """
199
+ if prune_existing:
200
+ rebates_to_pruned = cls.objects.filter(account__in=main_account.get_descendants(include_self=True))
201
+ if only_content_object_ids:
202
+ rebates_to_pruned = rebates_to_pruned.filter(product__in=only_content_object_ids)
203
+ if start_date:
204
+ rebates_to_pruned = rebates_to_pruned.filter(date__gte=start_date)
205
+ rebates_to_pruned.delete()
206
+ updated_objs = []
207
+ created_objs = []
208
+ for commission_type in CommissionType.objects.all():
209
+ for (
210
+ terminal_account,
211
+ compute_date,
212
+ commission,
213
+ content_object,
214
+ recipient,
215
+ recipient_fees,
216
+ audit_log,
217
+ ) in commission_type.compute_rebates(
218
+ main_account, start_date=start_date, only_content_object_ids=only_content_object_ids, **filter_kwargs
219
+ ):
220
+ try:
221
+ rebate = Rebate.objects.get(
222
+ date=compute_date,
223
+ recipient=recipient,
224
+ account=terminal_account,
225
+ product=content_object,
226
+ commission_type=commission_type,
227
+ )
228
+ rebate.value = recipient_fees
229
+ rebate.commission = commission
230
+ rebate.audit_log = audit_log
231
+ updated_objs.append(rebate)
232
+ except cls.DoesNotExist:
233
+ rebate = Rebate(
234
+ date=compute_date,
235
+ recipient=recipient,
236
+ account=terminal_account,
237
+ product=content_object,
238
+ commission_type=commission_type,
239
+ commission=commission,
240
+ value=recipient_fees,
241
+ audit_log=audit_log,
242
+ )
243
+ created_objs.append(rebate)
244
+
245
+ cls.objects.bulk_update(updated_objs, ["value", "commission"], batch_size=10000)
246
+ cls.objects.bulk_create(created_objs, batch_size=10000, ignore_conflicts=True)
247
+
248
+ @classmethod
249
+ def get_accounting_sum(cls, related_data: dict[str, str], queryset: QuerySet) -> float:
250
+ return (queryset.aggregate(s=models.Sum(related_data["field"]))["s"] or 0) * -1
251
+
252
+ def update_calculated_value_of_booking_entry(self):
253
+ try:
254
+ BookingEntry = apps.get_model("wbaccounting", "BookingEntry") # type: ignore
255
+ booking_entries = BookingEntry.objects.filter( # type: ignore
256
+ related_data__model="wbcommission.Rebate",
257
+ related_data__data__date_gte__lte=self.date.strftime("%Y-%m-%d"),
258
+ related_data__data__date_lte__gte=self.date.strftime("%Y-%m-%d"),
259
+ related_data__data__recipient_id=self.recipient.id,
260
+ related_data__data__product_id=self.product.id,
261
+ )
262
+
263
+ for booking_entry in booking_entries:
264
+ booking_entry.calculated_value = booking_entry.get_related_data_accounting_sum() # type: ignore
265
+ booking_entry.save() # type: ignore
266
+ except LookupError:
267
+ pass
268
+
269
+
270
+ # ----------- TASKS -----------
271
+
272
+
273
+ @shared_task(queue="commission")
274
+ def manage_rebate_as_task(
275
+ main_account_id: int,
276
+ start_date: date_lib | None = None,
277
+ only_content_object_ids: list[int] | None = None,
278
+ terminal_account_id: int | None = None,
279
+ user: User | None = None,
280
+ **kwargs,
281
+ ):
282
+ account = Account.objects.get(id=main_account_id)
283
+ terminal_account_filter_dict = dict(id=terminal_account_id) if terminal_account_id else dict()
284
+ Rebate.manage_rebate(
285
+ main_account=account,
286
+ start_date=start_date,
287
+ only_content_object_ids=only_content_object_ids,
288
+ terminal_account_filter_dict=terminal_account_filter_dict,
289
+ **kwargs,
290
+ )
291
+ if user:
292
+ send_notification(
293
+ code="wbcommission.rebate.computation_done",
294
+ title="Rebate Computation Done",
295
+ body=f"The rebate computation for root account {account} is done",
296
+ user=user,
297
+ )
298
+
299
+
300
+ @receiver(pre_merge, sender="wbcrm.Account")
301
+ def handle_pre_merge_account_for_rebate(sender: models.Model, merged_object: Account, main_object: Account, **kwargs):
302
+ """
303
+ Aggregate the rebate if it exists already for the main account. Otherwise, reassign the account to point to the main account
304
+ """
305
+ for rebate in Rebate.objects.filter(account=merged_object).select_for_update():
306
+ try:
307
+ existing_rebate = Rebate.objects.get(
308
+ date=rebate.date,
309
+ recipient=rebate.recipient,
310
+ account=main_object,
311
+ product=rebate.product,
312
+ commission_type=rebate.commission_type,
313
+ )
314
+ existing_rebate.value += rebate.value
315
+ existing_rebate.save()
316
+ rebate.delete()
317
+ except Rebate.DoesNotExist:
318
+ rebate.account = main_object
319
+ rebate.save()
@@ -0,0 +1,45 @@
1
+ from datetime import date
2
+
3
+ from django.db.models.signals import post_save
4
+ from django.dispatch import receiver
5
+ from dynamic_preferences.registries import global_preferences_registry
6
+ from wbcrm.models.accounts import Account
7
+ from wbportfolio.models import Claim
8
+
9
+ from .rebate import manage_rebate_as_task
10
+
11
+
12
+ # This file contains the different receiver directly linked to wbportfolio. In a future iteration, this will be moved outside of wbcommission and we will use a generic commission signal
13
+ @receiver(post_save, sender="wbportfolio.Claim")
14
+ def post_claim_save_for_rebate_computation(sender, instance, created, **kwargs):
15
+ # if a new commission line is created, we create a general rule
16
+ if instance.status == Claim.Status.APPROVED and instance.account and (root_account := instance.account.get_root()):
17
+ if isinstance(
18
+ instance.date, str
19
+ ): # we need to do this in case claim are created manually with date as string (allowed). This corner case led to date being still a string when it hits this signal
20
+ instance.refresh_from_db()
21
+ manage_rebate_as_task.delay(
22
+ root_account.id,
23
+ start_date=instance.date,
24
+ only_content_object_ids=[instance.product.id],
25
+ terminal_account_id=instance.account.id,
26
+ )
27
+
28
+
29
+ @receiver(post_save, sender="wbportfolio.Fees")
30
+ def post_fees_save_for_rebate_computation(sender, instance, created, **kwargs):
31
+ # if a new commission line is created, we create a general rule
32
+ if (
33
+ created
34
+ and (date.today() - instance.transaction_date).days
35
+ <= global_preferences_registry.manager()["wbcommission__days_to_recompute_rebate_from_fees_threshold"]
36
+ ): # we make sure that the fee won't trigger rebate computation if they are created too much in the past
37
+ for root_account in Account.objects.filter(level=0):
38
+ if Claim.objects.filter(
39
+ account__in=root_account.get_descendants(include_self=True), product=instance.linked_product
40
+ ).exists():
41
+ manage_rebate_as_task.delay(
42
+ root_account.id,
43
+ start_date=instance.transaction_date,
44
+ only_content_object_ids=[instance.linked_product.id],
45
+ )
@@ -0,0 +1,6 @@
1
+ from rest_framework.permissions import BasePermission
2
+
3
+
4
+ class IsCommissionAdmin(BasePermission):
5
+ def has_permission(self, request, view):
6
+ return request.user.has_perm("wbcommission.administrate_commission")
File without changes
@@ -0,0 +1,51 @@
1
+ from datetime import date
2
+ from io import BytesIO
3
+
4
+ import pandas as pd
5
+ from celery import shared_task
6
+ from wbcommission.models import Commission, CommissionType, Rebate
7
+ from wbcore.contrib.authentication.models import User
8
+ from wbcore.contrib.directory.models import Entry
9
+ from wbcrm.models.accounts import Account
10
+ from wbportfolio.models.products import Product
11
+
12
+ from .utils import create_report_and_send
13
+
14
+
15
+ @shared_task(queue="commission")
16
+ def create_audit_report_and_send_as_task(user_id: int, recipient_id: int, start_date: date, end_date: date):
17
+ user = User.objects.get(id=user_id)
18
+ recipient = Entry.objects.get(id=recipient_id)
19
+ create_report_and_send(user, recipient, start_date, end_date, create_report)
20
+
21
+
22
+ def create_report(user, customer, start_date, end_date):
23
+ rebates = Rebate.objects.filter(recipient=customer, date__gte=start_date, date__lte=end_date).filter_for_user(user)
24
+ df = pd.DataFrame(
25
+ rebates.values(
26
+ "date",
27
+ "account",
28
+ "product",
29
+ "recipient",
30
+ "commission_type",
31
+ "commission",
32
+ "value",
33
+ "audit_log",
34
+ )
35
+ )
36
+ buffer = BytesIO()
37
+ if df.empty:
38
+ raise ValueError("There is not rebate for this customer and given time period")
39
+ df = pd.concat([df[df.columns.difference(["audit_log"])], pd.json_normalize(df["audit_log"])], axis=1).sort_values(
40
+ by="date"
41
+ )
42
+ # we deserialize ids into human readable name
43
+ df.commission_type = df.commission_type.map(dict(CommissionType.objects.values_list("id", "name")))
44
+ df["product"] = df["product"].map(dict(Product.objects.values_list("id", "computed_str")))
45
+ df.commission = df.commission.map(
46
+ dict(map(lambda x: (x, str(Commission.objects.get(id=x))), df.commission.unique()))
47
+ )
48
+ df.recipient = df.recipient.map(dict(Entry.objects.values_list("id", "computed_str")))
49
+ df.account = df.account.map(dict(Account.objects.values_list("id", "computed_str")))
50
+ df.to_csv(buffer, index=False, mode="wb", encoding="UTF-8")
51
+ return buffer, "audit_report_{}_{}_{}.csv".format(customer.computed_str, start_date, end_date), "application/csv"