arthexis 0.1.23__py3-none-any.whl → 0.1.25__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
@@ -50,6 +50,7 @@ import uuid
50
50
  import requests
51
51
  import datetime
52
52
  from django.db import IntegrityError, transaction
53
+ from django.db.models import Q
53
54
  import calendar
54
55
  import re
55
56
  from django_object_actions import DjangoObjectActions
@@ -63,7 +64,7 @@ from reportlab.graphics.barcode import qr
63
64
  from reportlab.graphics.shapes import Drawing
64
65
  from reportlab.lib.styles import getSampleStyleSheet
65
66
  from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
66
- from ocpp.models import Transaction
67
+ from ocpp.models import Charger, Transaction
67
68
  from ocpp.rfid.utils import build_mode_toggle
68
69
  from nodes.models import EmailOutbox
69
70
  from .github_helper import GitHubRepositoryError, create_repository_for_package
@@ -2265,199 +2266,15 @@ class ProductAdminForm(forms.ModelForm):
2265
2266
  widgets = {"odoo_product": OdooProductWidget}
2266
2267
 
2267
2268
 
2268
- class ProductFetchWizardForm(forms.Form):
2269
- name = forms.CharField(label="Name", required=False)
2270
- default_code = forms.CharField(label="Internal reference", required=False)
2271
- barcode = forms.CharField(label="Barcode", required=False)
2272
- renewal_period = forms.IntegerField(
2273
- label="Renewal period (days)", min_value=1, initial=30
2274
- )
2275
-
2276
- def __init__(self, *args, require_search_terms=True, **kwargs):
2277
- self.require_search_terms = require_search_terms
2278
- super().__init__(*args, **kwargs)
2279
-
2280
- def clean(self):
2281
- cleaned = super().clean()
2282
- if self.require_search_terms:
2283
- if not any(
2284
- cleaned.get(field) for field in ("name", "default_code", "barcode")
2285
- ):
2286
- raise forms.ValidationError(
2287
- _("Enter at least one field to search for a product.")
2288
- )
2289
- return cleaned
2290
-
2291
- def build_domain(self):
2292
- domain = []
2293
- if self.cleaned_data.get("name"):
2294
- domain.append(("name", "ilike", self.cleaned_data["name"]))
2295
- if self.cleaned_data.get("default_code"):
2296
- domain.append(("default_code", "ilike", self.cleaned_data["default_code"]))
2297
- if self.cleaned_data.get("barcode"):
2298
- domain.append(("barcode", "ilike", self.cleaned_data["barcode"]))
2299
- return domain
2300
-
2301
-
2302
2269
  @admin.register(Product)
2303
2270
  class ProductAdmin(EntityModelAdmin):
2304
2271
  form = ProductAdminForm
2305
- actions = ["fetch_odoo_product", "register_from_odoo"]
2272
+ actions = ["register_from_odoo"]
2306
2273
  change_list_template = "admin/core/product/change_list.html"
2307
2274
 
2308
2275
  def _odoo_profile_admin(self):
2309
2276
  return self.admin_site._registry.get(OdooProfile)
2310
2277
 
2311
- def _search_odoo_products(self, profile, form):
2312
- domain = form.build_domain()
2313
- return profile.execute(
2314
- "product.product",
2315
- "search_read",
2316
- [domain],
2317
- fields=[
2318
- "name",
2319
- "default_code",
2320
- "barcode",
2321
- "description_sale",
2322
- ],
2323
- limit=50,
2324
- )
2325
-
2326
- @admin.action(description="Fetch Odoo Product")
2327
- def fetch_odoo_product(self, request, queryset):
2328
- profile = getattr(request.user, "odoo_profile", None)
2329
- has_credentials = bool(profile and profile.is_verified)
2330
- profile_admin = self._odoo_profile_admin()
2331
- profile_url = None
2332
- if profile_admin is not None:
2333
- profile_url = profile_admin.get_my_profile_url(request)
2334
-
2335
- context = {
2336
- "opts": self.model._meta,
2337
- "queryset": queryset,
2338
- "action": "fetch_odoo_product",
2339
- "has_credentials": has_credentials,
2340
- "profile_url": profile_url,
2341
- }
2342
-
2343
- if not has_credentials:
2344
- context["credential_error"] = _(
2345
- "Configure your Odoo employee credentials before fetching products."
2346
- )
2347
- return TemplateResponse(
2348
- request, "admin/core/product/fetch_odoo.html", context
2349
- )
2350
-
2351
- is_import = "import" in request.POST
2352
- form_kwargs = {"require_search_terms": not is_import}
2353
- if request.method == "POST":
2354
- form = ProductFetchWizardForm(request.POST, **form_kwargs)
2355
- else:
2356
- form = ProductFetchWizardForm()
2357
-
2358
- results = None
2359
- selected_product_id = request.POST.get("product_id", "")
2360
-
2361
- if request.method == "POST" and form.is_valid():
2362
- try:
2363
- results = self._search_odoo_products(profile, form)
2364
- except Exception:
2365
- logger.exception(
2366
- "Failed to fetch Odoo products for user %s (profile_id=%s, host=%s, database=%s)",
2367
- getattr(getattr(request, "user", None), "pk", None),
2368
- getattr(profile, "pk", None),
2369
- getattr(profile, "host", None),
2370
- getattr(profile, "database", None),
2371
- )
2372
- form.add_error(None, _("Unable to fetch products from Odoo."))
2373
- results = []
2374
- else:
2375
- if is_import:
2376
- if not self.has_add_permission(request):
2377
- form.add_error(
2378
- None, _("You do not have permission to add products.")
2379
- )
2380
- else:
2381
- product_id = request.POST.get("product_id")
2382
- if not product_id:
2383
- form.add_error(None, _("Select a product to import."))
2384
- else:
2385
- try:
2386
- odoo_id = int(product_id)
2387
- except (TypeError, ValueError):
2388
- form.add_error(None, _("Invalid product selection."))
2389
- else:
2390
- match = next(
2391
- (item for item in results if item.get("id") == odoo_id),
2392
- None,
2393
- )
2394
- if not match:
2395
- form.add_error(
2396
- None,
2397
- _(
2398
- "The selected product was not found. Run the search again."
2399
- ),
2400
- )
2401
- else:
2402
- existing = self.model.objects.filter(
2403
- odoo_product__id=odoo_id
2404
- ).first()
2405
- if existing:
2406
- self.message_user(
2407
- request,
2408
- _(
2409
- "Product %(name)s already imported; opening existing record."
2410
- )
2411
- % {"name": existing.name},
2412
- level=messages.WARNING,
2413
- )
2414
- return HttpResponseRedirect(
2415
- reverse(
2416
- "admin:%s_%s_change"
2417
- % (
2418
- existing._meta.app_label,
2419
- existing._meta.model_name,
2420
- ),
2421
- args=[existing.pk],
2422
- )
2423
- )
2424
- product = self.model.objects.create(
2425
- name=match.get("name") or f"Odoo Product {odoo_id}",
2426
- description=match.get("description_sale", "") or "",
2427
- renewal_period=form.cleaned_data["renewal_period"],
2428
- odoo_product={
2429
- "id": odoo_id,
2430
- "name": match.get("name", ""),
2431
- },
2432
- )
2433
- self.log_addition(
2434
- request, product, "Imported product from Odoo"
2435
- )
2436
- self.message_user(
2437
- request,
2438
- _("Imported %(name)s from Odoo.")
2439
- % {"name": product.name},
2440
- )
2441
- return HttpResponseRedirect(
2442
- reverse(
2443
- "admin:%s_%s_change"
2444
- % (
2445
- product._meta.app_label,
2446
- product._meta.model_name,
2447
- ),
2448
- args=[product.pk],
2449
- )
2450
- )
2451
- context.update(
2452
- {
2453
- "form": form,
2454
- "results": results,
2455
- "selected_product_id": selected_product_id,
2456
- }
2457
- )
2458
- context["media"] = self.media + form.media
2459
- return TemplateResponse(request, "admin/core/product/fetch_odoo.html", context)
2460
-
2461
2278
  def get_urls(self):
2462
2279
  urls = super().get_urls()
2463
2280
  custom = [
@@ -2506,7 +2323,6 @@ class ProductAdmin(EntityModelAdmin):
2506
2323
  products = profile.execute(
2507
2324
  "product.product",
2508
2325
  "search_read",
2509
- [[]],
2510
2326
  fields=[
2511
2327
  "name",
2512
2328
  "description_sale",
@@ -3615,13 +3431,62 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3615
3431
  return JsonResponse(result, status=status)
3616
3432
 
3617
3433
 
3434
+ class ClientReportRecurrencyFilter(admin.SimpleListFilter):
3435
+ title = "Recurrency"
3436
+ parameter_name = "recurrency"
3437
+
3438
+ def lookups(self, request, model_admin):
3439
+ for value, label in ClientReportSchedule.PERIODICITY_CHOICES:
3440
+ yield (value, label)
3441
+
3442
+ def queryset(self, request, queryset):
3443
+ value = self.value()
3444
+ if not value:
3445
+ return queryset
3446
+ if value == ClientReportSchedule.PERIODICITY_NONE:
3447
+ return queryset.filter(
3448
+ Q(schedule__isnull=True) | Q(schedule__periodicity=value)
3449
+ )
3450
+ return queryset.filter(schedule__periodicity=value)
3451
+
3452
+
3618
3453
  @admin.register(ClientReport)
3619
3454
  class ClientReportAdmin(EntityModelAdmin):
3620
- list_display = ("created_on", "start_date", "end_date")
3455
+ list_display = (
3456
+ "created_on",
3457
+ "period_range",
3458
+ "owner",
3459
+ "recurrency_display",
3460
+ "total_kw_period_display",
3461
+ "download_link",
3462
+ )
3463
+ list_select_related = ("schedule", "owner")
3464
+ list_filter = ("owner", ClientReportRecurrencyFilter)
3621
3465
  readonly_fields = ("created_on", "data")
3622
3466
 
3623
3467
  change_list_template = "admin/core/clientreport/change_list.html"
3624
3468
 
3469
+ def period_range(self, obj):
3470
+ return str(obj)
3471
+
3472
+ period_range.short_description = "Period"
3473
+
3474
+ def recurrency_display(self, obj):
3475
+ return obj.periodicity_label
3476
+
3477
+ recurrency_display.short_description = "Recurrency"
3478
+
3479
+ def total_kw_period_display(self, obj):
3480
+ return f"{obj.total_kw_period:.2f}"
3481
+
3482
+ total_kw_period_display.short_description = "Total kW (period)"
3483
+
3484
+ def download_link(self, obj):
3485
+ url = reverse("admin:core_clientreport_download", args=[obj.pk])
3486
+ return format_html('<a href="{}">Download</a>', url)
3487
+
3488
+ download_link.short_description = "Download"
3489
+
3625
3490
  class ClientReportForm(forms.Form):
3626
3491
  PERIOD_CHOICES = [
3627
3492
  ("range", "Date range"),
@@ -3657,8 +3522,28 @@ class ClientReportAdmin(EntityModelAdmin):
3657
3522
  label="Month",
3658
3523
  required=False,
3659
3524
  widget=forms.DateInput(attrs={"type": "month"}),
3525
+ input_formats=["%Y-%m"],
3660
3526
  help_text="Generates the report for the calendar month that you select.",
3661
3527
  )
3528
+ language = forms.ChoiceField(
3529
+ label="Report language",
3530
+ choices=settings.LANGUAGES,
3531
+ help_text="Choose the language used for the generated report.",
3532
+ )
3533
+ title = forms.CharField(
3534
+ label="Report title",
3535
+ required=False,
3536
+ max_length=200,
3537
+ help_text="Optional heading that replaces the default report title.",
3538
+ )
3539
+ chargers = forms.ModelMultipleChoiceField(
3540
+ label="Charge points",
3541
+ queryset=Charger.objects.filter(connector_id__isnull=True)
3542
+ .order_by("display_name", "charger_id"),
3543
+ required=False,
3544
+ widget=forms.CheckboxSelectMultiple,
3545
+ help_text="Choose which charge points are included in the report.",
3546
+ )
3662
3547
  owner = forms.ModelChoiceField(
3663
3548
  queryset=get_user_model().objects.all(),
3664
3549
  required=False,
@@ -3691,6 +3576,13 @@ class ClientReportAdmin(EntityModelAdmin):
3691
3576
  and request.user.is_authenticated
3692
3577
  ):
3693
3578
  self.fields["owner"].initial = request.user.pk
3579
+ self.fields["chargers"].widget.attrs["class"] = "charger-options"
3580
+ language_initial = ClientReport.default_language()
3581
+ if request:
3582
+ language_initial = ClientReport.normalize_language(
3583
+ getattr(request, "LANGUAGE_CODE", language_initial)
3584
+ )
3585
+ self.fields["language"].initial = language_initial
3694
3586
 
3695
3587
  def clean(self):
3696
3588
  cleaned = super().clean()
@@ -3735,6 +3627,10 @@ class ClientReportAdmin(EntityModelAdmin):
3735
3627
  emails.append(candidate)
3736
3628
  return emails
3737
3629
 
3630
+ def clean_title(self):
3631
+ title = self.cleaned_data.get("title")
3632
+ return ClientReport.normalize_title(title)
3633
+
3738
3634
  def get_urls(self):
3739
3635
  urls = super().get_urls()
3740
3636
  custom = [
@@ -3743,6 +3639,11 @@ class ClientReportAdmin(EntityModelAdmin):
3743
3639
  self.admin_site.admin_view(self.generate_view),
3744
3640
  name="core_clientreport_generate",
3745
3641
  ),
3642
+ path(
3643
+ "generate/action/",
3644
+ self.admin_site.admin_view(self.generate_report),
3645
+ name="core_clientreport_generate_report",
3646
+ ),
3746
3647
  path(
3747
3648
  "download/<int:report_id>/",
3748
3649
  self.admin_site.admin_view(self.download_view),
@@ -3763,14 +3664,37 @@ class ClientReportAdmin(EntityModelAdmin):
3763
3664
  enable_emails = form.cleaned_data.get("enable_emails", False)
3764
3665
  disable_emails = not enable_emails
3765
3666
  recipients = form.cleaned_data.get("destinations") if enable_emails else []
3667
+ chargers = list(form.cleaned_data.get("chargers") or [])
3668
+ language = form.cleaned_data.get("language")
3669
+ title = form.cleaned_data.get("title")
3766
3670
  report = ClientReport.generate(
3767
3671
  form.cleaned_data["start"],
3768
3672
  form.cleaned_data["end"],
3769
3673
  owner=owner,
3770
3674
  recipients=recipients,
3771
3675
  disable_emails=disable_emails,
3676
+ chargers=chargers,
3677
+ language=language,
3678
+ title=title,
3772
3679
  )
3773
3680
  report.store_local_copy()
3681
+ if chargers:
3682
+ report.chargers.set(chargers)
3683
+ if enable_emails and recipients:
3684
+ delivered = report.send_delivery(
3685
+ to=recipients,
3686
+ cc=[],
3687
+ outbox=ClientReport.resolve_outbox_for_owner(owner),
3688
+ reply_to=ClientReport.resolve_reply_to_for_owner(owner),
3689
+ )
3690
+ if delivered:
3691
+ report.recipients = delivered
3692
+ report.save(update_fields=["recipients"])
3693
+ self.message_user(
3694
+ request,
3695
+ "Consumer report emailed to the selected recipients.",
3696
+ messages.SUCCESS,
3697
+ )
3774
3698
  recurrence = form.cleaned_data.get("recurrence")
3775
3699
  if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
3776
3700
  schedule = ClientReportSchedule.objects.create(
@@ -3779,12 +3703,16 @@ class ClientReportAdmin(EntityModelAdmin):
3779
3703
  periodicity=recurrence,
3780
3704
  email_recipients=recipients,
3781
3705
  disable_emails=disable_emails,
3706
+ language=language,
3707
+ title=title,
3782
3708
  )
3709
+ if chargers:
3710
+ schedule.chargers.set(chargers)
3783
3711
  report.schedule = schedule
3784
3712
  report.save(update_fields=["schedule"])
3785
3713
  self.message_user(
3786
3714
  request,
3787
- "Client report schedule created; future reports will be generated automatically.",
3715
+ "Consumer report schedule created; future reports will be generated automatically.",
3788
3716
  messages.SUCCESS,
3789
3717
  )
3790
3718
  if disable_emails:
@@ -3812,45 +3740,44 @@ class ClientReportAdmin(EntityModelAdmin):
3812
3740
  "report": report,
3813
3741
  "schedule": schedule,
3814
3742
  "download_url": download_url,
3815
- "previous_reports": self._build_report_history(request),
3743
+ "opts": self.model._meta,
3816
3744
  }
3817
3745
  )
3818
3746
  return TemplateResponse(
3819
3747
  request, "admin/core/clientreport/generate.html", context
3820
3748
  )
3821
3749
 
3750
+ def get_changelist_actions(self, request):
3751
+ parent = getattr(super(), "get_changelist_actions", None)
3752
+ actions: list[str] = []
3753
+ if callable(parent):
3754
+ parent_actions = parent(request)
3755
+ if parent_actions:
3756
+ actions.extend(parent_actions)
3757
+ if "generate_report" not in actions:
3758
+ actions.append("generate_report")
3759
+ return actions
3760
+
3761
+ def generate_report(self, request):
3762
+ return HttpResponseRedirect(reverse("admin:core_clientreport_generate"))
3763
+
3764
+ generate_report.label = _("Generate report")
3765
+
3822
3766
  def download_view(self, request, report_id: int):
3823
3767
  report = get_object_or_404(ClientReport, pk=report_id)
3824
3768
  pdf_path = report.ensure_pdf()
3825
3769
  if not pdf_path.exists():
3826
3770
  raise Http404("Report file unavailable")
3827
- filename = f"consumer-report-{report.start_date}-{report.end_date}.pdf"
3771
+ end_date = report.end_date
3772
+ if hasattr(end_date, "isoformat"):
3773
+ end_date_str = end_date.isoformat()
3774
+ else: # pragma: no cover - fallback for unexpected values
3775
+ end_date_str = str(end_date)
3776
+ filename = f"consumer-report-{end_date_str}.pdf"
3828
3777
  response = FileResponse(pdf_path.open("rb"), content_type="application/pdf")
3829
3778
  response["Content-Disposition"] = f'attachment; filename="{filename}"'
3830
3779
  return response
3831
3780
 
3832
- def _build_report_history(self, request):
3833
- queryset = ClientReport.objects.order_by("-created_on")[:20]
3834
- history = []
3835
- for item in queryset:
3836
- totals = item.rows_for_display.get("totals", {})
3837
- history.append(
3838
- {
3839
- "instance": item,
3840
- "download_url": reverse(
3841
- "admin:core_clientreport_download", args=[item.pk]
3842
- ),
3843
- "email_enabled": not item.disable_emails,
3844
- "recipients": item.recipients or [],
3845
- "totals": {
3846
- "total_kw": totals.get("total_kw", 0.0),
3847
- "total_kw_period": totals.get("total_kw_period", 0.0),
3848
- },
3849
- }
3850
- )
3851
- return history
3852
-
3853
-
3854
3781
  @admin.register(PackageRelease)
3855
3782
  class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3856
3783
  change_list_template = "admin/core/packagerelease/change_list.html"
core/backends.py CHANGED
@@ -217,7 +217,9 @@ class LocalhostAdminBackend(ModelBackend):
217
217
  try:
218
218
  ipaddress.ip_address(host)
219
219
  except ValueError:
220
- if not self._is_test_environment(request):
220
+ if host.lower() == "localhost":
221
+ host = "127.0.0.1"
222
+ elif not self._is_test_environment(request):
221
223
  return None
222
224
  forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
223
225
  if forwarded: