arthexis 0.1.16__py3-none-any.whl → 0.1.26__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.

Files changed (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
core/admin.py CHANGED
@@ -1,3 +1,4 @@
1
+ from collections import defaultdict
1
2
  from io import BytesIO
2
3
  import os
3
4
 
@@ -8,10 +9,13 @@ from django.urls import NoReverseMatch, path, reverse
8
9
  from urllib.parse import urlencode, urlparse
9
10
  from django.shortcuts import get_object_or_404, redirect, render
10
11
  from django.http import (
12
+ FileResponse,
13
+ Http404,
11
14
  HttpResponse,
12
15
  JsonResponse,
13
16
  HttpResponseBase,
14
17
  HttpResponseRedirect,
18
+ HttpResponseNotAllowed,
15
19
  )
16
20
  from django.template.response import TemplateResponse
17
21
  from django.conf import settings
@@ -46,6 +50,7 @@ import uuid
46
50
  import requests
47
51
  import datetime
48
52
  from django.db import IntegrityError, transaction
53
+ from django.db.models import Q
49
54
  import calendar
50
55
  import re
51
56
  from django_object_actions import DjangoObjectActions
@@ -59,7 +64,7 @@ from reportlab.graphics.barcode import qr
59
64
  from reportlab.graphics.shapes import Drawing
60
65
  from reportlab.lib.styles import getSampleStyleSheet
61
66
  from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
62
- from ocpp.models import Transaction
67
+ from ocpp.models import Charger, Transaction
63
68
  from ocpp.rfid.utils import build_mode_toggle
64
69
  from nodes.models import EmailOutbox
65
70
  from .github_helper import GitHubRepositoryError, create_repository_for_package
@@ -82,6 +87,7 @@ from .models import (
82
87
  OdooProfile,
83
88
  OpenPayProfile,
84
89
  EmailInbox,
90
+ GoogleCalendarProfile,
85
91
  SocialProfile,
86
92
  EmailCollector,
87
93
  Package,
@@ -90,9 +96,7 @@ from .models import (
90
96
  SecurityGroup,
91
97
  InviteLead,
92
98
  PublicWifiAccess,
93
- AssistantProfile,
94
99
  Todo,
95
- hash_key,
96
100
  )
97
101
  from .user_data import (
98
102
  EntityModelAdmin,
@@ -109,8 +113,6 @@ from .rfid_import_export import (
109
113
  parse_accounts,
110
114
  serialize_accounts,
111
115
  )
112
- from .mcp import process as mcp_process
113
- from .mcp.server import resolve_base_urls
114
116
  from . import release as release_utils
115
117
 
116
118
  logger = logging.getLogger(__name__)
@@ -608,15 +610,18 @@ class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
608
610
  change_actions = ["create_repository_action", "prepare_next_release_action"]
609
611
 
610
612
  def _prepare(self, request, package):
613
+ if request.method not in {"POST", "GET"}:
614
+ return HttpResponseNotAllowed(["GET", "POST"])
611
615
  from pathlib import Path
612
616
  from packaging.version import Version
613
617
 
614
618
  ver_file = Path("VERSION")
615
- repo_version = (
616
- Version(ver_file.read_text().strip())
617
- if ver_file.exists()
618
- else Version("0.0.0")
619
- )
619
+ if ver_file.exists():
620
+ raw_version = ver_file.read_text().strip()
621
+ cleaned_version = raw_version.rstrip("+") or "0.0.0"
622
+ repo_version = Version(cleaned_version)
623
+ else:
624
+ repo_version = Version("0.0.0")
620
625
 
621
626
  pypi_latest = Version("0.0.0")
622
627
  try:
@@ -1008,6 +1013,41 @@ class OpenPayProfileAdminForm(forms.ModelForm):
1008
1013
  )
1009
1014
 
1010
1015
 
1016
+ class GoogleCalendarProfileAdminForm(forms.ModelForm):
1017
+ """Admin form for :class:`core.models.GoogleCalendarProfile`."""
1018
+
1019
+ api_key = forms.CharField(
1020
+ widget=forms.PasswordInput(render_value=True),
1021
+ required=False,
1022
+ help_text="Leave blank to keep the current key.",
1023
+ )
1024
+
1025
+ class Meta:
1026
+ model = GoogleCalendarProfile
1027
+ fields = "__all__"
1028
+
1029
+ def __init__(self, *args, **kwargs):
1030
+ super().__init__(*args, **kwargs)
1031
+ if self.instance.pk:
1032
+ self.fields["api_key"].initial = ""
1033
+ self.initial["api_key"] = ""
1034
+ else:
1035
+ self.fields["api_key"].required = True
1036
+
1037
+ def clean_api_key(self):
1038
+ key = self.cleaned_data.get("api_key")
1039
+ if not key and self.instance.pk:
1040
+ return keep_existing("api_key")
1041
+ return key
1042
+
1043
+ def _post_clean(self):
1044
+ super()._post_clean()
1045
+ _restore_sigil_values(
1046
+ self,
1047
+ ["calendar_id", "api_key", "display_name", "timezone"],
1048
+ )
1049
+
1050
+
1011
1051
  class MaskedPasswordFormMixin:
1012
1052
  """Mixin that hides stored passwords while allowing updates."""
1013
1053
 
@@ -1225,6 +1265,15 @@ class OpenPayProfileInlineForm(ProfileFormMixin, OpenPayProfileAdminForm):
1225
1265
  return cleaned
1226
1266
 
1227
1267
 
1268
+ class GoogleCalendarProfileInlineForm(
1269
+ ProfileFormMixin, GoogleCalendarProfileAdminForm
1270
+ ):
1271
+ profile_fields = GoogleCalendarProfile.profile_fields
1272
+
1273
+ class Meta(GoogleCalendarProfileAdminForm.Meta):
1274
+ exclude = ("user", "group")
1275
+
1276
+
1228
1277
  class EmailInboxInlineForm(ProfileFormMixin, EmailInboxAdminForm):
1229
1278
  profile_fields = EmailInbox.profile_fields
1230
1279
 
@@ -1303,46 +1352,6 @@ class ReleaseManagerInlineForm(ProfileFormMixin, forms.ModelForm):
1303
1352
  }
1304
1353
 
1305
1354
 
1306
- class AssistantProfileInlineForm(ProfileFormMixin, forms.ModelForm):
1307
- user_key = forms.CharField(
1308
- required=False,
1309
- widget=forms.PasswordInput(render_value=True),
1310
- help_text="Provide a plain key to create or rotate credentials.",
1311
- )
1312
- profile_fields = ("user_key", "scopes", "is_active")
1313
-
1314
- class Meta:
1315
- model = AssistantProfile
1316
- fields = ("scopes", "is_active")
1317
-
1318
- def __init__(self, *args, **kwargs):
1319
- super().__init__(*args, **kwargs)
1320
- if not self.instance.pk and "is_active" in self.fields:
1321
- self.fields["is_active"].initial = False
1322
-
1323
- def clean(self):
1324
- cleaned = super().clean()
1325
- if cleaned.get("DELETE"):
1326
- return cleaned
1327
- if not self.instance.pk and not cleaned.get("user_key"):
1328
- if cleaned.get("scopes") or cleaned.get("is_active"):
1329
- raise forms.ValidationError(
1330
- "Provide a user key to create an assistant profile."
1331
- )
1332
- return cleaned
1333
-
1334
- def save(self, commit=True):
1335
- instance = super().save(commit=False)
1336
- user_key = self.cleaned_data.get("user_key")
1337
- if user_key:
1338
- instance.user_key_hash = hash_key(user_key)
1339
- instance.last_used_at = None
1340
- if commit:
1341
- instance.save()
1342
- self.save_m2m()
1343
- return instance
1344
-
1345
-
1346
1355
  PROFILE_INLINE_CONFIG = {
1347
1356
  OdooProfile: {
1348
1357
  "form": OdooProfileInlineForm,
@@ -1389,6 +1398,16 @@ PROFILE_INLINE_CONFIG = {
1389
1398
  ),
1390
1399
  "readonly_fields": ("verified_on", "verification_reference"),
1391
1400
  },
1401
+ GoogleCalendarProfile: {
1402
+ "form": GoogleCalendarProfileInlineForm,
1403
+ "fields": (
1404
+ "display_name",
1405
+ "calendar_id",
1406
+ "api_key",
1407
+ "max_events",
1408
+ "timezone",
1409
+ ),
1410
+ },
1392
1411
  EmailInbox: {
1393
1412
  "form": EmailInboxInlineForm,
1394
1413
  "fields": (
@@ -1473,12 +1492,6 @@ PROFILE_INLINE_CONFIG = {
1473
1492
  "secondary_pypi_url",
1474
1493
  ),
1475
1494
  },
1476
- AssistantProfile: {
1477
- "form": AssistantProfileInlineForm,
1478
- "fields": ("user_key", "scopes", "is_active"),
1479
- "readonly_fields": ("user_key_hash", "created_at", "last_used_at"),
1480
- "template": "admin/edit_inline/profile_stacked.html",
1481
- },
1482
1495
  }
1483
1496
 
1484
1497
 
@@ -1525,7 +1538,6 @@ PROFILE_MODELS = (
1525
1538
  EmailOutbox,
1526
1539
  SocialProfile,
1527
1540
  ReleaseManager,
1528
- AssistantProfile,
1529
1541
  )
1530
1542
  USER_PROFILE_INLINES = [
1531
1543
  _build_profile_inline(model, "user") for model in PROFILE_MODELS
@@ -1867,6 +1879,44 @@ class OpenPayProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModel
1867
1879
  verify_credentials_action.short_description = _("Test credentials")
1868
1880
 
1869
1881
 
1882
+ class GoogleCalendarProfileAdmin(
1883
+ ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
1884
+ ):
1885
+ form = GoogleCalendarProfileAdminForm
1886
+ list_display = ("owner", "calendar_identifier", "max_events")
1887
+ search_fields = (
1888
+ "display_name",
1889
+ "calendar_id",
1890
+ "user__username",
1891
+ "group__name",
1892
+ )
1893
+ changelist_actions = ["my_profile"]
1894
+ change_actions = ["my_profile_action"]
1895
+ fieldsets = (
1896
+ (_("Owner"), {"fields": ("user", "group")}),
1897
+ (
1898
+ _("Calendar"),
1899
+ {
1900
+ "fields": (
1901
+ "display_name",
1902
+ "calendar_id",
1903
+ "api_key",
1904
+ "max_events",
1905
+ "timezone",
1906
+ )
1907
+ },
1908
+ ),
1909
+ )
1910
+
1911
+ @admin.display(description=_("Owner"))
1912
+ def owner(self, obj):
1913
+ return obj.owner_display()
1914
+
1915
+ @admin.display(description=_("Calendar"))
1916
+ def calendar_identifier(self, obj):
1917
+ display = obj.get_display_name()
1918
+ return display or obj.resolved_calendar_id()
1919
+
1870
1920
  class EmailSearchForm(forms.Form):
1871
1921
  subject = forms.CharField(
1872
1922
  required=False, widget=forms.TextInput(attrs={"style": "width: 40em;"})
@@ -2012,180 +2062,6 @@ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmi
2012
2062
  return TemplateResponse(request, "admin/core/emailinbox/search.html", context)
2013
2063
 
2014
2064
 
2015
- @admin.register(AssistantProfile)
2016
- class AssistantProfileAdmin(
2017
- ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
2018
- ):
2019
- list_display = ("owner", "created_at", "last_used_at", "is_active")
2020
- readonly_fields = ("user_key_hash", "created_at", "last_used_at")
2021
-
2022
- change_form_template = "admin/workgroupassistantprofile_change_form.html"
2023
- change_list_template = "admin/assistantprofile_change_list.html"
2024
- change_actions = ["my_profile_action"]
2025
- changelist_actions = ["my_profile"]
2026
- fieldsets = (
2027
- ("Owner", {"fields": ("user", "group")}),
2028
- ("Credentials", {"fields": ("user_key_hash",)}),
2029
- (
2030
- "Configuration",
2031
- {"fields": ("scopes", "is_active", "created_at", "last_used_at")},
2032
- ),
2033
- )
2034
-
2035
- def owner(self, obj):
2036
- return obj.owner_display()
2037
-
2038
- owner.short_description = "Owner"
2039
-
2040
- def get_urls(self):
2041
- urls = super().get_urls()
2042
- opts = self.model._meta
2043
- app_label = opts.app_label
2044
- model_name = opts.model_name
2045
- custom = [
2046
- path(
2047
- "<path:object_id>/generate-key/",
2048
- self.admin_site.admin_view(self.generate_key),
2049
- name=f"{app_label}_{model_name}_generate_key",
2050
- ),
2051
- path(
2052
- "server/start/",
2053
- self.admin_site.admin_view(self.start_server),
2054
- name=f"{app_label}_{model_name}_start_server",
2055
- ),
2056
- path(
2057
- "server/stop/",
2058
- self.admin_site.admin_view(self.stop_server),
2059
- name=f"{app_label}_{model_name}_stop_server",
2060
- ),
2061
- path(
2062
- "server/status/",
2063
- self.admin_site.admin_view(self.server_status),
2064
- name=f"{app_label}_{model_name}_status",
2065
- ),
2066
- ]
2067
- return custom + urls
2068
-
2069
- def changelist_view(self, request, extra_context=None):
2070
- extra_context = extra_context or {}
2071
- status = mcp_process.get_status()
2072
- opts = self.model._meta
2073
- app_label = opts.app_label
2074
- model_name = opts.model_name
2075
- extra_context.update(
2076
- {
2077
- "mcp_status": status,
2078
- "mcp_server_actions": {
2079
- "start": reverse(f"admin:{app_label}_{model_name}_start_server"),
2080
- "stop": reverse(f"admin:{app_label}_{model_name}_stop_server"),
2081
- "status": reverse(f"admin:{app_label}_{model_name}_status"),
2082
- },
2083
- }
2084
- )
2085
- return super().changelist_view(request, extra_context=extra_context)
2086
-
2087
- def _redirect_to_changelist(self):
2088
- opts = self.model._meta
2089
- return HttpResponseRedirect(
2090
- reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
2091
- )
2092
-
2093
- def generate_key(self, request, object_id, *args, **kwargs):
2094
- profile = self.get_object(request, object_id)
2095
- if profile is None:
2096
- return HttpResponseRedirect("../")
2097
- if profile.user is None:
2098
- self.message_user(
2099
- request,
2100
- "Assign a user before generating a key.",
2101
- level=messages.ERROR,
2102
- )
2103
- return HttpResponseRedirect("../")
2104
- profile, key = AssistantProfile.issue_key(profile.user)
2105
- context = {
2106
- **self.admin_site.each_context(request),
2107
- "opts": self.model._meta,
2108
- "original": profile,
2109
- "user_key": key,
2110
- }
2111
- return TemplateResponse(request, "admin/assistantprofile_key.html", context)
2112
-
2113
- def render_change_form(
2114
- self, request, context, add=False, change=False, form_url="", obj=None
2115
- ):
2116
- response = super().render_change_form(
2117
- request, context, add=add, change=change, form_url=form_url, obj=obj
2118
- )
2119
- config = dict(getattr(settings, "MCP_SIGIL_SERVER", {}))
2120
- host = config.get("host") or "127.0.0.1"
2121
- port = config.get("port", 8800)
2122
- base_url, issuer_url = resolve_base_urls(config)
2123
- mount_path = config.get("mount_path") or "/"
2124
- display_base_url = base_url or f"http://{host}:{port}"
2125
- display_issuer_url = issuer_url or display_base_url
2126
- chat_endpoint = f"{display_base_url.rstrip('/')}/api/chat/"
2127
- if isinstance(response, dict):
2128
- response.setdefault("mcp_server_host", host)
2129
- response.setdefault("mcp_server_port", port)
2130
- response.setdefault("mcp_server_base_url", display_base_url)
2131
- response.setdefault("mcp_server_issuer_url", display_issuer_url)
2132
- response.setdefault("mcp_server_mount_path", mount_path)
2133
- response.setdefault("mcp_server_chat_endpoint", chat_endpoint)
2134
- else:
2135
- context_data = getattr(response, "context_data", None)
2136
- if context_data is not None:
2137
- context_data.setdefault("mcp_server_host", host)
2138
- context_data.setdefault("mcp_server_port", port)
2139
- context_data.setdefault("mcp_server_base_url", display_base_url)
2140
- context_data.setdefault("mcp_server_issuer_url", display_issuer_url)
2141
- context_data.setdefault("mcp_server_mount_path", mount_path)
2142
- context_data.setdefault("mcp_server_chat_endpoint", chat_endpoint)
2143
- return response
2144
-
2145
- def start_server(self, request):
2146
- try:
2147
- pid = mcp_process.start_server()
2148
- except mcp_process.ServerAlreadyRunningError as exc:
2149
- self.message_user(request, str(exc), level=messages.WARNING)
2150
- except mcp_process.ServerStartError as exc:
2151
- self.message_user(request, str(exc), level=messages.ERROR)
2152
- else:
2153
- self.message_user(
2154
- request,
2155
- f"Started MCP server (PID {pid}).",
2156
- level=messages.SUCCESS,
2157
- )
2158
- return self._redirect_to_changelist()
2159
-
2160
- def stop_server(self, request):
2161
- try:
2162
- pid = mcp_process.stop_server()
2163
- except mcp_process.ServerNotRunningError as exc:
2164
- self.message_user(request, str(exc), level=messages.WARNING)
2165
- except mcp_process.ServerStopError as exc:
2166
- self.message_user(request, str(exc), level=messages.ERROR)
2167
- else:
2168
- self.message_user(
2169
- request,
2170
- f"Stopped MCP server (PID {pid}).",
2171
- level=messages.SUCCESS,
2172
- )
2173
- return self._redirect_to_changelist()
2174
-
2175
- def server_status(self, request):
2176
- status = mcp_process.get_status()
2177
- if status["running"]:
2178
- msg = f"MCP server is running (PID {status['pid']})."
2179
- level = messages.INFO
2180
- else:
2181
- msg = "MCP server is not running."
2182
- level = messages.WARNING
2183
- if status.get("last_error"):
2184
- msg = f"{msg} {status['last_error']}"
2185
- self.message_user(request, msg, level=level)
2186
- return self._redirect_to_changelist()
2187
-
2188
-
2189
2065
  class EnergyCreditInline(admin.TabularInline):
2190
2066
  model = EnergyCredit
2191
2067
  fields = ("amount_kw", "created_by", "created_on")
@@ -2390,199 +2266,15 @@ class ProductAdminForm(forms.ModelForm):
2390
2266
  widgets = {"odoo_product": OdooProductWidget}
2391
2267
 
2392
2268
 
2393
- class ProductFetchWizardForm(forms.Form):
2394
- name = forms.CharField(label="Name", required=False)
2395
- default_code = forms.CharField(label="Internal reference", required=False)
2396
- barcode = forms.CharField(label="Barcode", required=False)
2397
- renewal_period = forms.IntegerField(
2398
- label="Renewal period (days)", min_value=1, initial=30
2399
- )
2400
-
2401
- def __init__(self, *args, require_search_terms=True, **kwargs):
2402
- self.require_search_terms = require_search_terms
2403
- super().__init__(*args, **kwargs)
2404
-
2405
- def clean(self):
2406
- cleaned = super().clean()
2407
- if self.require_search_terms:
2408
- if not any(
2409
- cleaned.get(field) for field in ("name", "default_code", "barcode")
2410
- ):
2411
- raise forms.ValidationError(
2412
- _("Enter at least one field to search for a product.")
2413
- )
2414
- return cleaned
2415
-
2416
- def build_domain(self):
2417
- domain = []
2418
- if self.cleaned_data.get("name"):
2419
- domain.append(("name", "ilike", self.cleaned_data["name"]))
2420
- if self.cleaned_data.get("default_code"):
2421
- domain.append(("default_code", "ilike", self.cleaned_data["default_code"]))
2422
- if self.cleaned_data.get("barcode"):
2423
- domain.append(("barcode", "ilike", self.cleaned_data["barcode"]))
2424
- return domain
2425
-
2426
-
2427
2269
  @admin.register(Product)
2428
2270
  class ProductAdmin(EntityModelAdmin):
2429
2271
  form = ProductAdminForm
2430
- actions = ["fetch_odoo_product", "register_from_odoo"]
2272
+ actions = ["register_from_odoo"]
2431
2273
  change_list_template = "admin/core/product/change_list.html"
2432
2274
 
2433
2275
  def _odoo_profile_admin(self):
2434
2276
  return self.admin_site._registry.get(OdooProfile)
2435
2277
 
2436
- def _search_odoo_products(self, profile, form):
2437
- domain = form.build_domain()
2438
- return profile.execute(
2439
- "product.product",
2440
- "search_read",
2441
- [domain],
2442
- fields=[
2443
- "name",
2444
- "default_code",
2445
- "barcode",
2446
- "description_sale",
2447
- ],
2448
- limit=50,
2449
- )
2450
-
2451
- @admin.action(description="Fetch Odoo Product")
2452
- def fetch_odoo_product(self, request, queryset):
2453
- profile = getattr(request.user, "odoo_profile", None)
2454
- has_credentials = bool(profile and profile.is_verified)
2455
- profile_admin = self._odoo_profile_admin()
2456
- profile_url = None
2457
- if profile_admin is not None:
2458
- profile_url = profile_admin.get_my_profile_url(request)
2459
-
2460
- context = {
2461
- "opts": self.model._meta,
2462
- "queryset": queryset,
2463
- "action": "fetch_odoo_product",
2464
- "has_credentials": has_credentials,
2465
- "profile_url": profile_url,
2466
- }
2467
-
2468
- if not has_credentials:
2469
- context["credential_error"] = _(
2470
- "Configure your Odoo employee credentials before fetching products."
2471
- )
2472
- return TemplateResponse(
2473
- request, "admin/core/product/fetch_odoo.html", context
2474
- )
2475
-
2476
- is_import = "import" in request.POST
2477
- form_kwargs = {"require_search_terms": not is_import}
2478
- if request.method == "POST":
2479
- form = ProductFetchWizardForm(request.POST, **form_kwargs)
2480
- else:
2481
- form = ProductFetchWizardForm()
2482
-
2483
- results = None
2484
- selected_product_id = request.POST.get("product_id", "")
2485
-
2486
- if request.method == "POST" and form.is_valid():
2487
- try:
2488
- results = self._search_odoo_products(profile, form)
2489
- except Exception:
2490
- logger.exception(
2491
- "Failed to fetch Odoo products for user %s (profile_id=%s, host=%s, database=%s)",
2492
- getattr(getattr(request, "user", None), "pk", None),
2493
- getattr(profile, "pk", None),
2494
- getattr(profile, "host", None),
2495
- getattr(profile, "database", None),
2496
- )
2497
- form.add_error(None, _("Unable to fetch products from Odoo."))
2498
- results = []
2499
- else:
2500
- if is_import:
2501
- if not self.has_add_permission(request):
2502
- form.add_error(
2503
- None, _("You do not have permission to add products.")
2504
- )
2505
- else:
2506
- product_id = request.POST.get("product_id")
2507
- if not product_id:
2508
- form.add_error(None, _("Select a product to import."))
2509
- else:
2510
- try:
2511
- odoo_id = int(product_id)
2512
- except (TypeError, ValueError):
2513
- form.add_error(None, _("Invalid product selection."))
2514
- else:
2515
- match = next(
2516
- (item for item in results if item.get("id") == odoo_id),
2517
- None,
2518
- )
2519
- if not match:
2520
- form.add_error(
2521
- None,
2522
- _(
2523
- "The selected product was not found. Run the search again."
2524
- ),
2525
- )
2526
- else:
2527
- existing = self.model.objects.filter(
2528
- odoo_product__id=odoo_id
2529
- ).first()
2530
- if existing:
2531
- self.message_user(
2532
- request,
2533
- _(
2534
- "Product %(name)s already imported; opening existing record."
2535
- )
2536
- % {"name": existing.name},
2537
- level=messages.WARNING,
2538
- )
2539
- return HttpResponseRedirect(
2540
- reverse(
2541
- "admin:%s_%s_change"
2542
- % (
2543
- existing._meta.app_label,
2544
- existing._meta.model_name,
2545
- ),
2546
- args=[existing.pk],
2547
- )
2548
- )
2549
- product = self.model.objects.create(
2550
- name=match.get("name") or f"Odoo Product {odoo_id}",
2551
- description=match.get("description_sale", "") or "",
2552
- renewal_period=form.cleaned_data["renewal_period"],
2553
- odoo_product={
2554
- "id": odoo_id,
2555
- "name": match.get("name", ""),
2556
- },
2557
- )
2558
- self.log_addition(
2559
- request, product, "Imported product from Odoo"
2560
- )
2561
- self.message_user(
2562
- request,
2563
- _("Imported %(name)s from Odoo.")
2564
- % {"name": product.name},
2565
- )
2566
- return HttpResponseRedirect(
2567
- reverse(
2568
- "admin:%s_%s_change"
2569
- % (
2570
- product._meta.app_label,
2571
- product._meta.model_name,
2572
- ),
2573
- args=[product.pk],
2574
- )
2575
- )
2576
- context.update(
2577
- {
2578
- "form": form,
2579
- "results": results,
2580
- "selected_product_id": selected_product_id,
2581
- }
2582
- )
2583
- context["media"] = self.media + form.media
2584
- return TemplateResponse(request, "admin/core/product/fetch_odoo.html", context)
2585
-
2586
2278
  def get_urls(self):
2587
2279
  urls = super().get_urls()
2588
2280
  custom = [
@@ -2631,7 +2323,6 @@ class ProductAdmin(EntityModelAdmin):
2631
2323
  products = profile.execute(
2632
2324
  "product.product",
2633
2325
  "search_read",
2634
- [[]],
2635
2326
  fields=[
2636
2327
  "name",
2637
2328
  "description_sale",
@@ -2640,8 +2331,30 @@ class ProductAdmin(EntityModelAdmin):
2640
2331
  ],
2641
2332
  limit=0,
2642
2333
  )
2643
- except Exception:
2334
+ except Exception as exc:
2335
+ logger.exception(
2336
+ "Failed to fetch Odoo products for user %s (profile_id=%s, host=%s, database=%s)",
2337
+ getattr(getattr(request, "user", None), "pk", None),
2338
+ getattr(profile, "pk", None),
2339
+ getattr(profile, "host", None),
2340
+ getattr(profile, "database", None),
2341
+ )
2644
2342
  context["error"] = _("Unable to fetch products from Odoo.")
2343
+ if getattr(request.user, "is_superuser", False):
2344
+ fault = getattr(exc, "faultString", "")
2345
+ message = str(exc)
2346
+ details = [
2347
+ f"Host: {getattr(profile, 'host', '')}",
2348
+ f"Database: {getattr(profile, 'database', '')}",
2349
+ f"User ID: {getattr(profile, 'odoo_uid', '')}",
2350
+ ]
2351
+ if fault and fault != message:
2352
+ details.append(f"Fault: {fault}")
2353
+ if message:
2354
+ details.append(f"Exception: {type(exc).__name__}: {message}")
2355
+ else:
2356
+ details.append(f"Exception type: {type(exc).__name__}")
2357
+ context["debug_error"] = "\n".join(details)
2645
2358
  return context, []
2646
2359
 
2647
2360
  context["has_credentials"] = True
@@ -2835,6 +2548,7 @@ class RFIDResource(resources.ModelResource):
2835
2548
  "post_auth_command",
2836
2549
  "allowed",
2837
2550
  "color",
2551
+ "endianness",
2838
2552
  "kind",
2839
2553
  "released",
2840
2554
  "last_seen_on",
@@ -2849,6 +2563,7 @@ class RFIDResource(resources.ModelResource):
2849
2563
  "post_auth_command",
2850
2564
  "allowed",
2851
2565
  "color",
2566
+ "endianness",
2852
2567
  "kind",
2853
2568
  "released",
2854
2569
  "last_seen_on",
@@ -2899,7 +2614,7 @@ class CopyRFIDForm(forms.Form):
2899
2614
  normalized = (cleaned or "").strip().upper()
2900
2615
  if not normalized:
2901
2616
  raise forms.ValidationError(_("RFID value is required."))
2902
- if RFID.objects.filter(rfid=normalized).exists():
2617
+ if RFID.matching_queryset(normalized).exists():
2903
2618
  raise forms.ValidationError(
2904
2619
  _("An RFID with this value already exists.")
2905
2620
  )
@@ -2919,11 +2634,12 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2919
2634
  "user_data_flag",
2920
2635
  "color",
2921
2636
  "kind",
2637
+ "endianness_short",
2922
2638
  "released",
2923
2639
  "allowed",
2924
2640
  "last_seen_on",
2925
2641
  )
2926
- list_filter = ("color", "released", "allowed")
2642
+ list_filter = ("color", "endianness", "released", "allowed")
2927
2643
  search_fields = ("label_id", "rfid", "custom_label")
2928
2644
  autocomplete_fields = ["energy_accounts"]
2929
2645
  raw_id_fields = ["reference"]
@@ -2932,9 +2648,12 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2932
2648
  "print_card_labels",
2933
2649
  "print_release_form",
2934
2650
  "copy_rfids",
2651
+ "merge_rfids",
2935
2652
  "toggle_selected_user_data",
2653
+ "toggle_selected_released",
2654
+ "toggle_selected_allowed",
2936
2655
  ]
2937
- readonly_fields = ("added_on", "last_seen_on")
2656
+ readonly_fields = ("added_on", "last_seen_on", "reversed_uid")
2938
2657
  form = RFIDForm
2939
2658
 
2940
2659
  def get_import_resource_kwargs(self, request, form=None, **kwargs):
@@ -2980,6 +2699,14 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
2980
2699
  def user_data_flag(self, obj):
2981
2700
  return getattr(obj, "is_user_data", False)
2982
2701
 
2702
+ @admin.display(description=_("End"), ordering="endianness")
2703
+ def endianness_short(self, obj):
2704
+ labels = {
2705
+ RFID.BIG_ENDIAN: _("Big"),
2706
+ RFID.LITTLE_ENDIAN: _("Little"),
2707
+ }
2708
+ return labels.get(obj.endianness, obj.get_endianness_display())
2709
+
2983
2710
  def scan_rfids(self, request, queryset):
2984
2711
  return redirect("admin:core_rfid_scan")
2985
2712
 
@@ -3030,6 +2757,50 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3030
2757
  level=messages.WARNING,
3031
2758
  )
3032
2759
 
2760
+ @admin.action(description=_("Toggle Released flag"))
2761
+ def toggle_selected_released(self, request, queryset):
2762
+ manager = getattr(self.model, "all_objects", self.model.objects)
2763
+ toggled = 0
2764
+ for tag in queryset:
2765
+ new_state = not tag.released
2766
+ manager.filter(pk=tag.pk).update(released=new_state)
2767
+ tag.released = new_state
2768
+ toggled += 1
2769
+
2770
+ if toggled:
2771
+ self.message_user(
2772
+ request,
2773
+ ngettext(
2774
+ "Toggled released flag for %(count)d RFID.",
2775
+ "Toggled released flag for %(count)d RFIDs.",
2776
+ toggled,
2777
+ )
2778
+ % {"count": toggled},
2779
+ level=messages.SUCCESS,
2780
+ )
2781
+
2782
+ @admin.action(description=_("Toggle Allowed flag"))
2783
+ def toggle_selected_allowed(self, request, queryset):
2784
+ manager = getattr(self.model, "all_objects", self.model.objects)
2785
+ toggled = 0
2786
+ for tag in queryset:
2787
+ new_state = not tag.allowed
2788
+ manager.filter(pk=tag.pk).update(allowed=new_state)
2789
+ tag.allowed = new_state
2790
+ toggled += 1
2791
+
2792
+ if toggled:
2793
+ self.message_user(
2794
+ request,
2795
+ ngettext(
2796
+ "Toggled allowed flag for %(count)d RFID.",
2797
+ "Toggled allowed flag for %(count)d RFIDs.",
2798
+ toggled,
2799
+ )
2800
+ % {"count": toggled},
2801
+ level=messages.SUCCESS,
2802
+ )
2803
+
3033
2804
  @admin.action(description=_("Copy RFID"))
3034
2805
  def copy_rfids(self, request, queryset):
3035
2806
  if queryset.count() != 1:
@@ -3120,6 +2891,145 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3120
2891
  context["media"] = self.media + form.media
3121
2892
  return TemplateResponse(request, "admin/core/rfid/copy.html", context)
3122
2893
 
2894
+ @admin.action(description=_("Merge RFID cards"))
2895
+ def merge_rfids(self, request, queryset):
2896
+ tags = list(queryset.prefetch_related("energy_accounts"))
2897
+ if len(tags) < 2:
2898
+ self.message_user(
2899
+ request,
2900
+ _("Select at least two RFIDs to merge."),
2901
+ level=messages.WARNING,
2902
+ )
2903
+ return None
2904
+
2905
+ normalized_map: dict[int, str] = {}
2906
+ groups: defaultdict[str, list[RFID]] = defaultdict(list)
2907
+ unmatched = 0
2908
+ for tag in tags:
2909
+ normalized = RFID.normalize_code(tag.rfid)
2910
+ normalized_map[tag.pk] = normalized
2911
+ if not normalized:
2912
+ unmatched += 1
2913
+ continue
2914
+ prefix = normalized[: RFID.MATCH_PREFIX_LENGTH]
2915
+ groups[prefix].append(tag)
2916
+
2917
+ merge_groups: list[list[RFID]] = []
2918
+ skipped = unmatched
2919
+ for prefix, group in groups.items():
2920
+ if len(group) < 2:
2921
+ skipped += len(group)
2922
+ continue
2923
+ group.sort(
2924
+ key=lambda item: (
2925
+ len(normalized_map.get(item.pk, "")),
2926
+ normalized_map.get(item.pk, ""),
2927
+ item.pk,
2928
+ )
2929
+ )
2930
+ merge_groups.append(group)
2931
+
2932
+ if not merge_groups:
2933
+ self.message_user(
2934
+ request,
2935
+ _("No matching RFIDs were found to merge."),
2936
+ level=messages.WARNING,
2937
+ )
2938
+ return None
2939
+
2940
+ merged_tags = 0
2941
+ merged_groups = 0
2942
+ conflicting_accounts = 0
2943
+ with transaction.atomic():
2944
+ for group in merge_groups:
2945
+ canonical = group[0]
2946
+ update_fields: set[str] = set()
2947
+ existing_account_ids = set(
2948
+ canonical.energy_accounts.values_list("pk", flat=True)
2949
+ )
2950
+ for tag in group[1:]:
2951
+ other_value = normalized_map.get(tag.pk, "")
2952
+ if canonical.adopt_rfid(other_value):
2953
+ update_fields.add("rfid")
2954
+ normalized_map[canonical.pk] = RFID.normalize_code(
2955
+ canonical.rfid
2956
+ )
2957
+ accounts = list(tag.energy_accounts.all())
2958
+ if accounts:
2959
+ transferable: list[EnergyAccount] = []
2960
+ for account in accounts:
2961
+ if existing_account_ids and account.pk not in existing_account_ids:
2962
+ conflicting_accounts += 1
2963
+ continue
2964
+ transferable.append(account)
2965
+ if transferable:
2966
+ canonical.energy_accounts.add(*transferable)
2967
+ existing_account_ids.update(
2968
+ account.pk for account in transferable
2969
+ )
2970
+ if tag.allowed and not canonical.allowed:
2971
+ canonical.allowed = True
2972
+ update_fields.add("allowed")
2973
+ if tag.released and not canonical.released:
2974
+ canonical.released = True
2975
+ update_fields.add("released")
2976
+ if tag.key_a_verified and not canonical.key_a_verified:
2977
+ canonical.key_a_verified = True
2978
+ update_fields.add("key_a_verified")
2979
+ if tag.key_b_verified and not canonical.key_b_verified:
2980
+ canonical.key_b_verified = True
2981
+ update_fields.add("key_b_verified")
2982
+ if tag.last_seen_on and (
2983
+ not canonical.last_seen_on
2984
+ or tag.last_seen_on > canonical.last_seen_on
2985
+ ):
2986
+ canonical.last_seen_on = tag.last_seen_on
2987
+ update_fields.add("last_seen_on")
2988
+ if not canonical.origin_node and tag.origin_node_id:
2989
+ canonical.origin_node = tag.origin_node
2990
+ update_fields.add("origin_node")
2991
+ merged_tags += 1
2992
+ tag.delete()
2993
+ if update_fields:
2994
+ canonical.save(update_fields=sorted(update_fields))
2995
+ merged_groups += 1
2996
+
2997
+ if merged_tags:
2998
+ self.message_user(
2999
+ request,
3000
+ ngettext(
3001
+ "Merged %(removed)d RFID into %(groups)d canonical record.",
3002
+ "Merged %(removed)d RFIDs into %(groups)d canonical records.",
3003
+ merged_tags,
3004
+ )
3005
+ % {"removed": merged_tags, "groups": merged_groups},
3006
+ level=messages.SUCCESS,
3007
+ )
3008
+
3009
+ if skipped:
3010
+ self.message_user(
3011
+ request,
3012
+ ngettext(
3013
+ "Skipped %(count)d RFID because it did not share the first %(length)d characters with another selection.",
3014
+ "Skipped %(count)d RFIDs because they did not share the first %(length)d characters with another selection.",
3015
+ skipped,
3016
+ )
3017
+ % {"count": skipped, "length": RFID.MATCH_PREFIX_LENGTH},
3018
+ level=messages.WARNING,
3019
+ )
3020
+
3021
+ if conflicting_accounts:
3022
+ self.message_user(
3023
+ request,
3024
+ ngettext(
3025
+ "Skipped %(count)d energy account because the RFID was already linked to a different account.",
3026
+ "Skipped %(count)d energy accounts because the RFID was already linked to a different account.",
3027
+ conflicting_accounts,
3028
+ )
3029
+ % {"count": conflicting_accounts},
3030
+ level=messages.WARNING,
3031
+ )
3032
+
3123
3033
  def _render_card_labels(
3124
3034
  self,
3125
3035
  request,
@@ -3492,11 +3402,13 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3492
3402
  "toggle_url": toggle_url,
3493
3403
  "toggle_label": toggle_label,
3494
3404
  "public_view_url": public_view_url,
3405
+ "deep_read_url": reverse("rfid-scan-deep"),
3495
3406
  }
3496
3407
  )
3497
3408
  context["title"] = _("Scan RFIDs")
3498
3409
  context["opts"] = self.model._meta
3499
3410
  context["show_release_info"] = True
3411
+ context["default_endianness"] = RFID.BIG_ENDIAN
3500
3412
  return render(request, "admin/core/rfid/scan.html", context)
3501
3413
 
3502
3414
  def scan_next(self, request):
@@ -3510,20 +3422,71 @@ class RFIDAdmin(EntityModelAdmin, ImportExportModelAdmin):
3510
3422
  return JsonResponse({"error": "Invalid JSON payload"}, status=400)
3511
3423
  rfid = payload.get("rfid") or payload.get("value")
3512
3424
  kind = payload.get("kind")
3513
- result = validate_rfid_value(rfid, kind=kind)
3425
+ endianness = payload.get("endianness")
3426
+ result = validate_rfid_value(rfid, kind=kind, endianness=endianness)
3514
3427
  else:
3515
- result = scan_sources(request)
3428
+ endianness = request.GET.get("endianness")
3429
+ result = scan_sources(request, endianness=endianness)
3516
3430
  status = 500 if result.get("error") else 200
3517
3431
  return JsonResponse(result, status=status)
3518
3432
 
3519
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
+
3520
3453
  @admin.register(ClientReport)
3521
3454
  class ClientReportAdmin(EntityModelAdmin):
3522
- 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)
3523
3465
  readonly_fields = ("created_on", "data")
3524
3466
 
3525
3467
  change_list_template = "admin/core/clientreport/change_list.html"
3526
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
+
3527
3490
  class ClientReportForm(forms.Form):
3528
3491
  PERIOD_CHOICES = [
3529
3492
  ("range", "Date range"),
@@ -3559,8 +3522,28 @@ class ClientReportAdmin(EntityModelAdmin):
3559
3522
  label="Month",
3560
3523
  required=False,
3561
3524
  widget=forms.DateInput(attrs={"type": "month"}),
3525
+ input_formats=["%Y-%m"],
3562
3526
  help_text="Generates the report for the calendar month that you select.",
3563
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
+ )
3564
3547
  owner = forms.ModelChoiceField(
3565
3548
  queryset=get_user_model().objects.all(),
3566
3549
  required=False,
@@ -3578,10 +3561,10 @@ class ClientReportAdmin(EntityModelAdmin):
3578
3561
  initial=ClientReportSchedule.PERIODICITY_NONE,
3579
3562
  help_text="Defines how often the report should be generated automatically.",
3580
3563
  )
3581
- disable_emails = forms.BooleanField(
3582
- label="Disable email delivery",
3564
+ enable_emails = forms.BooleanField(
3565
+ label="Enable email delivery",
3583
3566
  required=False,
3584
- help_text="Generate files without sending emails.",
3567
+ help_text="Send the report via email to the recipients listed above.",
3585
3568
  )
3586
3569
 
3587
3570
  def __init__(self, *args, request=None, **kwargs):
@@ -3593,6 +3576,13 @@ class ClientReportAdmin(EntityModelAdmin):
3593
3576
  and request.user.is_authenticated
3594
3577
  ):
3595
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
3596
3586
 
3597
3587
  def clean(self):
3598
3588
  cleaned = super().clean()
@@ -3637,6 +3627,10 @@ class ClientReportAdmin(EntityModelAdmin):
3637
3627
  emails.append(candidate)
3638
3628
  return emails
3639
3629
 
3630
+ def clean_title(self):
3631
+ title = self.cleaned_data.get("title")
3632
+ return ClientReport.normalize_title(title)
3633
+
3640
3634
  def get_urls(self):
3641
3635
  urls = super().get_urls()
3642
3636
  custom = [
@@ -3645,6 +3639,16 @@ class ClientReportAdmin(EntityModelAdmin):
3645
3639
  self.admin_site.admin_view(self.generate_view),
3646
3640
  name="core_clientreport_generate",
3647
3641
  ),
3642
+ path(
3643
+ "generate/action/",
3644
+ self.admin_site.admin_view(self.generate_report),
3645
+ name="core_clientreport_generate_report",
3646
+ ),
3647
+ path(
3648
+ "download/<int:report_id>/",
3649
+ self.admin_site.admin_view(self.download_view),
3650
+ name="core_clientreport_download",
3651
+ ),
3648
3652
  ]
3649
3653
  return custom + urls
3650
3654
 
@@ -3652,40 +3656,127 @@ class ClientReportAdmin(EntityModelAdmin):
3652
3656
  form = self.ClientReportForm(request.POST or None, request=request)
3653
3657
  report = None
3654
3658
  schedule = None
3659
+ download_url = None
3655
3660
  if request.method == "POST" and form.is_valid():
3656
3661
  owner = form.cleaned_data.get("owner")
3657
3662
  if not owner and request.user.is_authenticated:
3658
3663
  owner = request.user
3664
+ enable_emails = form.cleaned_data.get("enable_emails", False)
3665
+ disable_emails = not enable_emails
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")
3659
3670
  report = ClientReport.generate(
3660
3671
  form.cleaned_data["start"],
3661
3672
  form.cleaned_data["end"],
3662
3673
  owner=owner,
3663
- recipients=form.cleaned_data.get("destinations"),
3664
- disable_emails=form.cleaned_data.get("disable_emails", False),
3674
+ recipients=recipients,
3675
+ disable_emails=disable_emails,
3676
+ chargers=chargers,
3677
+ language=language,
3678
+ title=title,
3665
3679
  )
3666
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
+ )
3667
3698
  recurrence = form.cleaned_data.get("recurrence")
3668
3699
  if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
3669
3700
  schedule = ClientReportSchedule.objects.create(
3670
3701
  owner=owner,
3671
3702
  created_by=request.user if request.user.is_authenticated else None,
3672
3703
  periodicity=recurrence,
3673
- email_recipients=form.cleaned_data.get("destinations", []),
3674
- disable_emails=form.cleaned_data.get("disable_emails", False),
3704
+ email_recipients=recipients,
3705
+ disable_emails=disable_emails,
3706
+ language=language,
3707
+ title=title,
3675
3708
  )
3709
+ if chargers:
3710
+ schedule.chargers.set(chargers)
3676
3711
  report.schedule = schedule
3677
3712
  report.save(update_fields=["schedule"])
3678
3713
  self.message_user(
3679
3714
  request,
3680
- "Client report schedule created; future reports will be generated automatically.",
3715
+ "Consumer report schedule created; future reports will be generated automatically.",
3681
3716
  messages.SUCCESS,
3682
3717
  )
3718
+ if disable_emails:
3719
+ self.message_user(
3720
+ request,
3721
+ "Consumer report generated. The download will begin automatically.",
3722
+ messages.SUCCESS,
3723
+ )
3724
+ redirect_url = f"{reverse('admin:core_clientreport_generate')}?download={report.pk}"
3725
+ return HttpResponseRedirect(redirect_url)
3726
+ download_param = request.GET.get("download")
3727
+ if download_param:
3728
+ try:
3729
+ download_report = ClientReport.objects.get(pk=download_param)
3730
+ except ClientReport.DoesNotExist:
3731
+ pass
3732
+ else:
3733
+ download_url = reverse(
3734
+ "admin:core_clientreport_download", args=[download_report.pk]
3735
+ )
3683
3736
  context = self.admin_site.each_context(request)
3684
- context.update({"form": form, "report": report, "schedule": schedule})
3737
+ context.update(
3738
+ {
3739
+ "form": form,
3740
+ "report": report,
3741
+ "schedule": schedule,
3742
+ "download_url": download_url,
3743
+ "opts": self.model._meta,
3744
+ }
3745
+ )
3685
3746
  return TemplateResponse(
3686
3747
  request, "admin/core/clientreport/generate.html", context
3687
3748
  )
3688
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
+
3766
+ def download_view(self, request, report_id: int):
3767
+ report = get_object_or_404(ClientReport, pk=report_id)
3768
+ pdf_path = report.ensure_pdf()
3769
+ if not pdf_path.exists():
3770
+ raise Http404("Report file unavailable")
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"
3777
+ response = FileResponse(pdf_path.open("rb"), content_type="application/pdf")
3778
+ response["Content-Disposition"] = f'attachment; filename="{filename}"'
3779
+ return response
3689
3780
 
3690
3781
  @admin.register(PackageRelease)
3691
3782
  class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
@@ -3693,6 +3784,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3693
3784
  list_display = (
3694
3785
  "version",
3695
3786
  "package_link",
3787
+ "severity",
3696
3788
  "is_current",
3697
3789
  "pypi_url",
3698
3790
  "github_url",
@@ -3709,6 +3801,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3709
3801
  "package",
3710
3802
  "release_manager",
3711
3803
  "version",
3804
+ "severity",
3712
3805
  "revision",
3713
3806
  "is_current",
3714
3807
  "pypi_url",
@@ -3746,9 +3839,9 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3746
3839
  self.message_user(request, str(exc), messages.ERROR)
3747
3840
  return
3748
3841
  releases = resp.json().get("releases", {})
3749
- created = 0
3750
3842
  updated = 0
3751
3843
  restored = 0
3844
+ missing: list[str] = []
3752
3845
 
3753
3846
  for version, files in releases.items():
3754
3847
  release_on = self._release_on_from_files(files)
@@ -3773,23 +3866,11 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3773
3866
  if update_fields:
3774
3867
  release.save(update_fields=update_fields)
3775
3868
  continue
3776
- PackageRelease.objects.create(
3777
- package=package,
3778
- release_manager=package.release_manager,
3779
- version=version,
3780
- revision="",
3781
- pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
3782
- release_on=release_on,
3783
- )
3784
- created += 1
3869
+ missing.append(version)
3785
3870
 
3786
- if created or updated or restored:
3871
+ if updated or restored:
3787
3872
  PackageRelease.dump_fixture()
3788
3873
  message_parts = []
3789
- if created:
3790
- message_parts.append(
3791
- f"Created {created} release{'s' if created != 1 else ''} from PyPI"
3792
- )
3793
3874
  if updated:
3794
3875
  message_parts.append(
3795
3876
  f"Updated release date for {updated} release"
@@ -3800,8 +3881,17 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
3800
3881
  f"Restored {restored} release{'s' if restored != 1 else ''}"
3801
3882
  )
3802
3883
  self.message_user(request, "; ".join(message_parts), messages.SUCCESS)
3803
- else:
3804
- self.message_user(request, "No new releases found", messages.INFO)
3884
+ elif not missing:
3885
+ self.message_user(request, "No matching releases found", messages.INFO)
3886
+
3887
+ if missing:
3888
+ versions = ", ".join(sorted(missing))
3889
+ count = len(missing)
3890
+ message = (
3891
+ "Manual creation required for "
3892
+ f"{count} release{'s' if count != 1 else ''}: {versions}"
3893
+ )
3894
+ self.message_user(request, message, messages.WARNING)
3805
3895
 
3806
3896
  refresh_from_pypi.label = "Refresh from PyPI"
3807
3897
  refresh_from_pypi.short_description = "Refresh from PyPI"