arthexis 0.1.26__py3-none-any.whl → 0.1.28__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 arthexis might be problematic. Click here for more details.

core/admin.py CHANGED
@@ -43,6 +43,7 @@ from django.utils import timezone, translation
43
43
  from django.utils.formats import date_format
44
44
  from django.utils.dateparse import parse_datetime
45
45
  from django.utils.html import format_html
46
+ from django.utils.text import slugify
46
47
  from django.utils.translation import gettext_lazy as _, ngettext
47
48
  from django.forms.models import BaseInlineFormSet
48
49
  import json
@@ -813,6 +814,7 @@ class SecurityGroupAdmin(DjangoGroupAdmin):
813
814
  form = SecurityGroupAdminForm
814
815
  fieldsets = ((None, {"fields": ("name", "parent", "users", "permissions")}),)
815
816
  filter_horizontal = ("permissions",)
817
+ search_fields = ("name", "parent__name")
816
818
 
817
819
 
818
820
  class InviteLeadAdmin(EntityModelAdmin):
@@ -881,6 +883,82 @@ class EnergyAccountRFIDInline(admin.TabularInline):
881
883
  verbose_name_plural = "RFIDs"
882
884
 
883
885
 
886
+ class UserChangeRFIDForm(forms.ModelForm):
887
+ """Admin change form exposing login RFID assignment."""
888
+
889
+ login_rfid = forms.ModelChoiceField(
890
+ label=_("Login RFID"),
891
+ queryset=RFID.objects.none(),
892
+ required=False,
893
+ help_text=_("Assign an RFID card to this user for RFID logins."),
894
+ )
895
+
896
+ class Meta:
897
+ model = User
898
+ fields = "__all__"
899
+
900
+ def __init__(self, *args, **kwargs):
901
+ super().__init__(*args, **kwargs)
902
+ user = self.instance
903
+ field = self.fields["login_rfid"]
904
+ account = getattr(user, "energy_account", None)
905
+ if account is not None:
906
+ queryset = RFID.objects.filter(
907
+ Q(energy_accounts__isnull=True) | Q(energy_accounts=account)
908
+ )
909
+ current = account.rfids.order_by("label_id").first()
910
+ if current:
911
+ field.initial = current.pk
912
+ else:
913
+ queryset = RFID.objects.filter(energy_accounts__isnull=True)
914
+ field.queryset = queryset.order_by("label_id")
915
+ field.empty_label = _("Keep current assignment")
916
+
917
+ def _ensure_energy_account(self, user):
918
+ account = getattr(user, "energy_account", None)
919
+ if account is not None:
920
+ if account.user_id != user.pk:
921
+ account.user = user
922
+ account.save(update_fields=["user"])
923
+ return account
924
+ account = EnergyAccount.objects.filter(user=user).first()
925
+ if account is not None:
926
+ if account.user_id != user.pk:
927
+ account.user = user
928
+ account.save(update_fields=["user"])
929
+ return account
930
+ base_slug = slugify(
931
+ user.username
932
+ or user.get_full_name()
933
+ or user.email
934
+ or (str(user.pk) if user.pk is not None else "")
935
+ )
936
+ if not base_slug:
937
+ base_slug = f"user-{uuid.uuid4().hex[:8]}"
938
+ base_name = base_slug.upper()
939
+ candidate = base_name
940
+ suffix = 1
941
+ while EnergyAccount.objects.filter(name=candidate).exists():
942
+ suffix += 1
943
+ candidate = f"{base_name}-{suffix}"
944
+ return EnergyAccount.objects.create(user=user, name=candidate)
945
+
946
+ def save(self, commit=True):
947
+ user = super().save(commit)
948
+ rfid = self.cleaned_data.get("login_rfid")
949
+ if not rfid:
950
+ return user
951
+ account = self._ensure_energy_account(user)
952
+ if account.pk is None:
953
+ account.save()
954
+ other_accounts = list(rfid.energy_accounts.exclude(pk=account.pk))
955
+ if other_accounts:
956
+ rfid.energy_accounts.remove(*other_accounts)
957
+ if not account.rfids.filter(pk=rfid.pk).exists():
958
+ account.rfids.add(rfid)
959
+ return user
960
+
961
+
884
962
  def _raw_instance_value(instance, field_name):
885
963
  """Return the stored value for ``field_name`` without resolving sigils."""
886
964
 
@@ -978,6 +1056,15 @@ class OpenPayProfileAdminForm(forms.ModelForm):
978
1056
  required=False,
979
1057
  help_text="Leave blank to keep the current secret.",
980
1058
  )
1059
+ paypal_client_secret = forms.CharField(
1060
+ widget=forms.PasswordInput(render_value=True),
1061
+ required=False,
1062
+ help_text="Leave blank to keep the current secret.",
1063
+ )
1064
+ paypal_webhook_id = forms.CharField(
1065
+ required=False,
1066
+ help_text="Leave blank to keep the current webhook identifier.",
1067
+ )
981
1068
 
982
1069
  class Meta:
983
1070
  model = OpenPayProfile
@@ -985,13 +1072,43 @@ class OpenPayProfileAdminForm(forms.ModelForm):
985
1072
 
986
1073
  def __init__(self, *args, **kwargs):
987
1074
  super().__init__(*args, **kwargs)
1075
+ openpay_help = _(
1076
+ "Provide merchant ID, public and private keys, and webhook secret from OpenPay."
1077
+ )
1078
+ self.fields["merchant_id"].help_text = openpay_help
1079
+ self.fields["public_key"].help_text = _(
1080
+ "OpenPay public key used for browser integrations."
1081
+ )
1082
+ self.fields["private_key"].help_text = _(
1083
+ "OpenPay private key used for server-side requests. Leave blank to keep the current key."
1084
+ )
1085
+ self.fields["webhook_secret"].help_text = _(
1086
+ "Secret used to sign OpenPay webhooks. Leave blank to keep the current secret."
1087
+ )
1088
+ self.fields["is_production"].help_text = _(
1089
+ "Enable to send requests to OpenPay's live environment."
1090
+ )
1091
+ self.fields["default_processor"].help_text = _(
1092
+ "Select which configured processor to try first when charging."
1093
+ )
1094
+ self.fields["paypal_client_id"].help_text = _(
1095
+ "PayPal REST client ID for your application."
1096
+ )
1097
+ self.fields["paypal_client_secret"].help_text = _(
1098
+ "PayPal REST client secret. Leave blank to keep the current secret."
1099
+ )
1100
+ self.fields["paypal_webhook_id"].help_text = _(
1101
+ "PayPal webhook ID used to validate notifications. Leave blank to keep the current webhook identifier."
1102
+ )
1103
+ self.fields["paypal_is_production"].help_text = _(
1104
+ "Enable to send requests to PayPal's live environment."
1105
+ )
1106
+
988
1107
  if self.instance.pk:
989
- for field in ("private_key", "webhook_secret"):
1108
+ for field in ("private_key", "webhook_secret", "paypal_client_secret", "paypal_webhook_id"):
990
1109
  if field in self.fields:
991
1110
  self.fields[field].initial = ""
992
1111
  self.initial[field] = ""
993
- else:
994
- self.fields["private_key"].required = True
995
1112
 
996
1113
  def clean_private_key(self):
997
1114
  key = self.cleaned_data.get("private_key")
@@ -1005,11 +1122,31 @@ class OpenPayProfileAdminForm(forms.ModelForm):
1005
1122
  return keep_existing("webhook_secret")
1006
1123
  return secret
1007
1124
 
1125
+ def clean_paypal_client_secret(self):
1126
+ secret = self.cleaned_data.get("paypal_client_secret")
1127
+ if not secret and self.instance.pk:
1128
+ return keep_existing("paypal_client_secret")
1129
+ return secret
1130
+
1131
+ def clean_paypal_webhook_id(self):
1132
+ identifier = self.cleaned_data.get("paypal_webhook_id")
1133
+ if identifier == "" and self.instance.pk:
1134
+ return keep_existing("paypal_webhook_id")
1135
+ return identifier
1136
+
1008
1137
  def _post_clean(self):
1009
1138
  super()._post_clean()
1010
1139
  _restore_sigil_values(
1011
1140
  self,
1012
- ["merchant_id", "private_key", "public_key", "webhook_secret"],
1141
+ [
1142
+ "merchant_id",
1143
+ "private_key",
1144
+ "public_key",
1145
+ "webhook_secret",
1146
+ "paypal_client_id",
1147
+ "paypal_client_secret",
1148
+ "paypal_webhook_id",
1149
+ ],
1013
1150
  )
1014
1151
 
1015
1152
 
@@ -1232,7 +1369,7 @@ class OdooProfileInlineForm(ProfileFormMixin, OdooProfileAdminForm):
1232
1369
  ]
1233
1370
  if provided and missing:
1234
1371
  raise forms.ValidationError(
1235
- "Provide host, database, username, and password to create an Odoo employee.",
1372
+ "Provide host, database, username, and password to create a CRM employee.",
1236
1373
  )
1237
1374
 
1238
1375
  return cleaned
@@ -1249,19 +1386,51 @@ class OpenPayProfileInlineForm(ProfileFormMixin, OpenPayProfileAdminForm):
1249
1386
  if cleaned.get("DELETE") or self.errors:
1250
1387
  return cleaned
1251
1388
 
1252
- required = ("merchant_id", "private_key", "public_key")
1253
- provided = [
1254
- name for name in required if not self._is_empty_value(cleaned.get(name))
1255
- ]
1256
- missing = [
1257
- name for name in required if self._is_empty_value(cleaned.get(name))
1258
- ]
1259
- if provided and missing:
1389
+ def _has_value(name: str) -> bool:
1390
+ value = cleaned.get(name)
1391
+ if isinstance(value, KeepExistingValue):
1392
+ return bool(getattr(self.instance, name))
1393
+ return not self._is_empty_value(value)
1394
+
1395
+ openpay_fields = ("merchant_id", "private_key", "public_key")
1396
+ openpay_provided = [name for name in openpay_fields if _has_value(name)]
1397
+ openpay_missing = [name for name in openpay_fields if not _has_value(name)]
1398
+ if openpay_provided and openpay_missing:
1260
1399
  raise forms.ValidationError(
1261
1400
  _(
1262
1401
  "Provide merchant ID, private key, and public key to configure OpenPay."
1263
1402
  )
1264
1403
  )
1404
+
1405
+ paypal_fields = ("paypal_client_id", "paypal_client_secret")
1406
+ paypal_provided = [name for name in paypal_fields if _has_value(name)]
1407
+ paypal_missing = [name for name in paypal_fields if not _has_value(name)]
1408
+ if paypal_provided and paypal_missing:
1409
+ raise forms.ValidationError(
1410
+ _("Provide PayPal client ID and client secret to configure PayPal.")
1411
+ )
1412
+
1413
+ has_openpay = len(openpay_provided) == len(openpay_fields)
1414
+ has_paypal = len(paypal_provided) == len(paypal_fields)
1415
+
1416
+ if not has_openpay and not has_paypal:
1417
+ raise forms.ValidationError(
1418
+ _("Provide OpenPay or PayPal credentials to configure a payment processor.")
1419
+ )
1420
+
1421
+ default_processor = cleaned.get("default_processor") or OpenPayProfile.PROCESSOR_OPENPAY
1422
+ if default_processor == OpenPayProfile.PROCESSOR_OPENPAY and not has_openpay:
1423
+ raise forms.ValidationError(
1424
+ _(
1425
+ "OpenPay must be fully configured or select PayPal as the default processor."
1426
+ )
1427
+ )
1428
+ if default_processor == OpenPayProfile.PROCESSOR_PAYPAL and not has_paypal:
1429
+ raise forms.ValidationError(
1430
+ _(
1431
+ "PayPal must be fully configured or select OpenPay as the default processor."
1432
+ )
1433
+ )
1265
1434
  return cleaned
1266
1435
 
1267
1436
 
@@ -1360,6 +1529,7 @@ PROFILE_INLINE_CONFIG = {
1360
1529
  None,
1361
1530
  {
1362
1531
  "fields": (
1532
+ "crm",
1363
1533
  "host",
1364
1534
  "database",
1365
1535
  "username",
@@ -1368,7 +1538,7 @@ PROFILE_INLINE_CONFIG = {
1368
1538
  },
1369
1539
  ),
1370
1540
  (
1371
- "Odoo Employee",
1541
+ "CRM Employee",
1372
1542
  {
1373
1543
  "fields": ("verified_on", "odoo_uid", "name", "email"),
1374
1544
  },
@@ -1556,12 +1726,25 @@ class UserPhoneNumberInline(admin.TabularInline):
1556
1726
 
1557
1727
 
1558
1728
  class UserAdmin(UserDatumAdminMixin, DjangoUserAdmin):
1729
+ form = UserChangeRFIDForm
1559
1730
  fieldsets = _append_operate_as(DjangoUserAdmin.fieldsets)
1560
1731
  add_fieldsets = _append_operate_as(DjangoUserAdmin.add_fieldsets)
1561
1732
  inlines = USER_PROFILE_INLINES + [UserPhoneNumberInline]
1562
1733
  change_form_template = "admin/user_profile_change_form.html"
1563
1734
  _skip_entity_user_datum = True
1564
1735
 
1736
+ def get_fieldsets(self, request, obj=None):
1737
+ fieldsets = list(super().get_fieldsets(request, obj))
1738
+ if obj is not None and fieldsets:
1739
+ name, options = fieldsets[0]
1740
+ fields = list(options.get("fields", ()))
1741
+ if "login_rfid" not in fields:
1742
+ fields.append("login_rfid")
1743
+ options = options.copy()
1744
+ options["fields"] = tuple(fields)
1745
+ fieldsets[0] = (name, options)
1746
+ return fieldsets
1747
+
1565
1748
  def _get_operate_as_profile_template(self):
1566
1749
  opts = self.model._meta
1567
1750
  try:
@@ -1769,17 +1952,18 @@ class SocialProfileAdmin(
1769
1952
  class OdooProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
1770
1953
  change_form_template = "django_object_actions/change_form.html"
1771
1954
  form = OdooProfileAdminForm
1772
- list_display = ("owner", "host", "database", "verified_on")
1955
+ list_display = ("owner", "host", "database", "credentials_ok", "verified_on")
1956
+ list_filter = ("crm",)
1773
1957
  readonly_fields = ("verified_on", "odoo_uid", "name", "email")
1774
1958
  actions = ["verify_credentials"]
1775
1959
  change_actions = ["verify_credentials_action", "my_profile_action"]
1776
1960
  changelist_actions = ["my_profile", "generate_quote_report"]
1777
1961
  fieldsets = (
1778
1962
  ("Owner", {"fields": ("user", "group")}),
1779
- ("Configuration", {"fields": ("host", "database")}),
1963
+ ("Configuration", {"fields": ("crm", "host", "database")}),
1780
1964
  ("Credentials", {"fields": ("username", "password")}),
1781
1965
  (
1782
- "Odoo Employee",
1966
+ "CRM Employee",
1783
1967
  {"fields": ("verified_on", "odoo_uid", "name", "email")},
1784
1968
  ),
1785
1969
  )
@@ -1789,6 +1973,10 @@ class OdooProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdm
1789
1973
 
1790
1974
  owner.short_description = "Owner"
1791
1975
 
1976
+ @admin.display(description=_("Credentials OK"), boolean=True)
1977
+ def credentials_ok(self, obj):
1978
+ return bool(obj.password) and obj.is_verified
1979
+
1792
1980
  def _verify_credentials(self, request, profile):
1793
1981
  try:
1794
1982
  profile.verify()
@@ -1820,15 +2008,47 @@ class OdooProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdm
1820
2008
  class OpenPayProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
1821
2009
  change_form_template = "django_object_actions/change_form.html"
1822
2010
  form = OpenPayProfileAdminForm
1823
- list_display = ("owner", "merchant_id", "environment", "verified_on")
2011
+ list_display = ("owner", "default_processor_display", "environment", "verified_on")
1824
2012
  readonly_fields = ("verified_on", "verification_reference")
1825
2013
  actions = ["verify_credentials"]
1826
2014
  change_actions = ["verify_credentials_action", "my_profile_action"]
1827
2015
  changelist_actions = ["my_profile"]
1828
2016
  fieldsets = (
1829
2017
  (_("Owner"), {"fields": ("user", "group")}),
1830
- (_("Gateway"), {"fields": ("merchant_id", "public_key", "is_production")}),
1831
- (_("Credentials"), {"fields": ("private_key", "webhook_secret")}),
2018
+ (
2019
+ _("Default Processor"),
2020
+ {
2021
+ "fields": ("default_processor",),
2022
+ "description": _(
2023
+ "Choose which configured processor to contact first when processing payments."
2024
+ ),
2025
+ },
2026
+ ),
2027
+ (
2028
+ _("OpenPay"),
2029
+ {
2030
+ "fields": (
2031
+ "merchant_id",
2032
+ "public_key",
2033
+ "private_key",
2034
+ "webhook_secret",
2035
+ "is_production",
2036
+ ),
2037
+ "description": _("Configure OpenPay merchant access."),
2038
+ },
2039
+ ),
2040
+ (
2041
+ _("PayPal"),
2042
+ {
2043
+ "fields": (
2044
+ "paypal_client_id",
2045
+ "paypal_client_secret",
2046
+ "paypal_webhook_id",
2047
+ "paypal_is_production",
2048
+ ),
2049
+ "description": _("Configure PayPal REST API access."),
2050
+ },
2051
+ ),
1832
2052
  (
1833
2053
  _("Verification"),
1834
2054
  {"fields": ("verified_on", "verification_reference")},
@@ -1839,12 +2059,23 @@ class OpenPayProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModel
1839
2059
  def owner(self, obj):
1840
2060
  return obj.owner_display()
1841
2061
 
2062
+ @admin.display(description=_("Default Processor"))
2063
+ def default_processor_display(self, obj):
2064
+ return obj.get_default_processor_display()
2065
+
1842
2066
  @admin.display(description=_("Environment"))
1843
2067
  def environment(self, obj):
1844
- return _("Production") if obj.is_production else _("Sandbox")
2068
+ if obj.default_processor == obj.PROCESSOR_PAYPAL:
2069
+ return _("PayPal Production") if obj.paypal_is_production else _("PayPal Sandbox")
2070
+ return _("OpenPay Production") if obj.is_production else _("OpenPay Sandbox")
1845
2071
 
1846
2072
  def _verify_credentials(self, request, profile):
1847
- owner = profile.owner_display() or profile.merchant_id
2073
+ owner = (
2074
+ profile.owner_display()
2075
+ or profile.merchant_id
2076
+ or profile.paypal_client_id
2077
+ or _("Payment Processor")
2078
+ )
1848
2079
  try:
1849
2080
  profile.verify()
1850
2081
  except ValidationError as exc:
@@ -2315,7 +2546,7 @@ class ProductAdmin(EntityModelAdmin):
2315
2546
  profile = getattr(request.user, "odoo_profile", None)
2316
2547
  if not profile or not profile.is_verified:
2317
2548
  context["credential_error"] = _(
2318
- "Configure your Odoo employee credentials before registering products."
2549
+ "Configure your CRM employee credentials before registering products."
2319
2550
  )
2320
2551
  return context, None
2321
2552
 
@@ -2523,6 +2754,12 @@ class RFIDResource(resources.ModelResource):
2523
2754
  account_column = account_column_for_field(account_field)
2524
2755
  self.fields["energy_accounts"].column_name = account_column
2525
2756
 
2757
+ def get_queryset(self):
2758
+ manager = getattr(self._meta.model, "all_objects", None)
2759
+ if manager is not None:
2760
+ return manager.all()
2761
+ return super().get_queryset()
2762
+
2526
2763
  def dehydrate_energy_accounts(self, obj):
2527
2764
  return serialize_accounts(obj, self.account_field)
2528
2765
 
@@ -2536,6 +2773,11 @@ class RFIDResource(resources.ModelResource):
2536
2773
  else:
2537
2774
  instance.energy_accounts.clear()
2538
2775
 
2776
+ def before_save_instance(self, instance, row, **kwargs):
2777
+ if getattr(instance, "is_deleted", False):
2778
+ instance.is_deleted = False
2779
+ super().before_save_instance(instance, row, **kwargs)
2780
+
2539
2781
  class Meta:
2540
2782
  model = RFID
2541
2783
  fields = (
core/apps.py CHANGED
@@ -38,6 +38,7 @@ class CoreConfig(AppConfig):
38
38
  patch_admin_sigil_builder_view,
39
39
  generate_model_sigils,
40
40
  )
41
+ from .celery_utils import normalize_periodic_task_name
41
42
  from .admin_history import patch_admin_history
42
43
 
43
44
  from django_otp.plugins.otp_totp.models import TOTPDevice as OTP_TOTPDevice
@@ -222,8 +223,11 @@ class CoreConfig(AppConfig):
222
223
  schedule, _ = IntervalSchedule.objects.get_or_create(
223
224
  every=1, period=IntervalSchedule.HOURS
224
225
  )
225
- PeriodicTask.objects.get_or_create(
226
- name="poll_email_collectors",
226
+ task_name = normalize_periodic_task_name(
227
+ PeriodicTask.objects, "poll_email_collectors"
228
+ )
229
+ PeriodicTask.objects.update_or_create(
230
+ name=task_name,
227
231
  defaults={
228
232
  "interval": schedule,
229
233
  "task": "core.tasks.poll_email_collectors",
core/celery_utils.py ADDED
@@ -0,0 +1,73 @@
1
+ """Utilities for working with Celery periodic task names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Set
7
+
8
+ from django.db import transaction
9
+ from django.db.utils import IntegrityError
10
+
11
+
12
+ def slugify_task_name(name: str) -> str:
13
+ """Return a slugified task name using dashes.
14
+
15
+ Celery stores periodic task names in the database and historically these
16
+ values included underscores or dotted module paths. The scheduler UI reads
17
+ these values directly, so we collapse consecutive underscores or dots into a
18
+ single dash to keep them human readable while remaining unique.
19
+ """
20
+
21
+ slug = re.sub(r"[._]+", "-", name)
22
+ # Collapse any accidental duplicate separators that may result from the
23
+ # replacement so ``foo__bar`` and ``foo..bar`` both become ``foo-bar``.
24
+ slug = re.sub(r"-{2,}", "-", slug)
25
+ return slug
26
+
27
+
28
+ def periodic_task_name_variants(name: str) -> Set[str]:
29
+ """Return legacy and slugified variants for a periodic task name."""
30
+
31
+ slug = slugify_task_name(name)
32
+ if slug == name:
33
+ return {name}
34
+ return {name, slug}
35
+
36
+
37
+ def normalize_periodic_task_name(manager, name: str) -> str:
38
+ """Ensure the stored periodic task name matches the slugified form.
39
+
40
+ The helper renames any rows that still use the legacy value so that follow-up
41
+ ``update_or_create`` calls keep working without leaving duplicate tasks in
42
+ the scheduler. When a conflicting slug already exists we keep the slugged
43
+ version and remove the legacy entry.
44
+ """
45
+
46
+ slug = slugify_task_name(name)
47
+ if slug == name:
48
+ return slug
49
+
50
+ for task in manager.filter(name=name):
51
+ conflict = manager.filter(name=slug).exclude(pk=task.pk).first()
52
+ if conflict:
53
+ # Preserve foreign key references when possible before removing the
54
+ # legacy row.
55
+ related_attr = getattr(task, "client_report_schedule", None)
56
+ if related_attr and getattr(conflict, "client_report_schedule", None) is None:
57
+ related_attr.periodic_task = conflict
58
+ related_attr.save(update_fields=["periodic_task"])
59
+ task.delete()
60
+ continue
61
+
62
+ task.name = slug
63
+ try:
64
+ with transaction.atomic():
65
+ task.save(update_fields=["name"])
66
+ except IntegrityError:
67
+ # Another process may have created the slug in between the select and
68
+ # the update. Fall back to deleting the legacy row to avoid duplicate
69
+ # scheduler entries.
70
+ task.refresh_from_db()
71
+ if task.name != slug:
72
+ task.delete()
73
+ return slug