arthexis 0.1.7__py3-none-any.whl → 0.1.8__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.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arthexis
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: Django-based MESH system
5
- Author-email: "Rafael J. Guillén-Osorio" <tecnologia@gelectriic.com>
5
+ Author-email: "Rafael J. Guillén-Osorio" <tecnologia@gelectriic.com>
6
6
  License-Expression: MIT
7
7
  Project-URL: Repository, https://github.com/arthexis/arthexis
8
8
  Project-URL: Homepage, https://arthexis.com
@@ -109,18 +109,22 @@ Dynamic: license-file
109
109
  # Arthexis Constellation
110
110
 
111
111
  ## Purpose
112
- Arthexis Constellation is a narrative-driven Django-based suite that centralizes tools for managing charging infrastructure and orchestrating energy related products and services.
112
+
113
+ Arthexis Constellation is a [narrative-driven](https://en.wikipedia.org/wiki/Narrative) [Django](https://www.djangoproject.com/)-based [software suite](https://en.wikipedia.org/wiki/Software_suite) that centralizes tools for managing [electric vehicle charging infrastructure](https://en.wikipedia.org/wiki/Charging_station) and orchestrating [energy](https://en.wikipedia.org/wiki/Energy)-related [products](https://en.wikipedia.org/wiki/Product_(business)) and [services](https://en.wikipedia.org/wiki/Service_(economics)).
113
114
 
114
115
  ## Features
115
- - Compatible with OCPP 1.6+ chargers
116
- - API integration with Odoo 1.6+
117
- - Runs on Windows and Linux (Ubuntu 16+)
118
- - One codebase. Six* specialized Roles.
116
+
117
+ - Compatible with the [Open Charge Point Protocol (OCPP) 1.6](https://www.openchargealliance.org/protocols/ocpp-16/)
118
+ - [API](https://en.wikipedia.org/wiki/API) integration with [Odoo](https://www.odoo.com/) 1.6
119
+ - Runs on [Windows 11](https://www.microsoft.com/windows/windows-11) and [Ubuntu 22.04 LTS](https://releases.ubuntu.com/22.04/)
120
+ - Tested for the [Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/)
119
121
 
120
122
  ## Support
121
- Contact us at tecnologia at gelectriic dot com or visit our [web page](https://www.gelectriic.com/) for professional services and commercial support.
123
+
124
+ Contact us at [tecnologia@gelectriic.com](mailto:tecnologia@gelectriic.com) or visit our [web page](https://www.gelectriic.com/) for [professional services](https://en.wikipedia.org/wiki/Professional_services) and [commercial support](https://en.wikipedia.org/wiki/Technical_support).
122
125
 
123
126
  ## About Me
124
- > "What, you wanna know about me too? Well, I enjoy developing software, role-playing games, long walks on the beach and a fourth secret thing."
127
+
128
+ > "What, you want to know about me too? Well, I enjoy [developing software](https://en.wikipedia.org/wiki/Software_development), [role-playing games](https://en.wikipedia.org/wiki/Role-playing_game), long walks on the [beach](https://en.wikipedia.org/wiki/Beach) and a fourth secret thing."
125
129
  > --Arthexis
126
130
 
@@ -1,4 +1,4 @@
1
- arthexis-0.1.7.dist-info/licenses/LICENSE,sha256=rBjcKtvD5yIpzAnkw3XRofK8KCAeL43DXmNaiRuYJrg,1092
1
+ arthexis-0.1.8.dist-info/licenses/LICENSE,sha256=rBjcKtvD5yIpzAnkw3XRofK8KCAeL43DXmNaiRuYJrg,1092
2
2
  config/__init__.py,sha256=5EyLFDrM6aTOHSnyBcLXRvMInZMPmAAy9Icu0cHHK5o,110
3
3
  config/active_app.py,sha256=MET_G7oHL7GkoSo3VkkMzymM-PwsSZazMLZxpgjFLTo,388
4
4
  config/asgi.py,sha256=n09URedOmQ_59II3UCl3iodGSDWOuN_A8DFyfLjuylA,803
@@ -10,12 +10,13 @@ config/loadenv.py,sha256=bhFbHTbRJSkSwrFk3UInKEKQ8ZY-poatOGi7rC57YAI,298
10
10
  config/logging.py,sha256=334jADN4dM5GNHaCWlYPOKYa5BhfxbsuejH_QDALG6g,1793
11
11
  config/middleware.py,sha256=EvraDumepnKwCDswHGXb1mK7vud_dEEoZ4eh0IQ7fhQ,744
12
12
  config/offline.py,sha256=7XHGFlV7IAWUJ93dEjknrHkAHDEyzWxK_hj_ht4-6-Q,1427
13
- config/settings.py,sha256=hyqTxQhVh1UYMRxZVuPil32IChJEvthJ7hkh2UGVehI,10897
14
- config/urls.py,sha256=mgC0PqY1JBH6GdOxM6A_p5rDzx9vDoEVAOrH36DWSB4,2991
13
+ config/settings.py,sha256=FN0lZfKFnp0fy3uV-ev3068wNWRKjNLOyFTUlE0Omz4,10922
14
+ config/urls.py,sha256=qUTXHIyfe74QljKtvGyEyK6ber1lo2PcHEK88RGagT4,3273
15
15
  config/workgroup_app.py,sha256=rm6KrjxtRNdkrxl2_in9FAaHpIltRUhpDrt4tP9yanE,202
16
16
  config/wsgi.py,sha256=Fu-ONO2SgYeU6rhmy909P-uLX-n8ALJQObdm9MHPS-k,450
17
17
  core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
- core/admin.py,sha256=bKncxl6b_dEPXfbIbJYMMF6Nf3a6Su6IZ7ymzy_RD8Q,29687
18
+ core/admin.py,sha256=XRnmIdFQ14KiQK2p9AAblmGQWSJUVXFc1cNBiybeAt4,34352
19
+ core/admindocs.py,sha256=uGM55KYZhHKViaNgqTqiIeRe6Y_87BfIE1DwmsbNMj4,1663
19
20
  core/apps.py,sha256=5wmxTrED-4WBWA0khEpqVAUH7V-g7DLtPDNEtSINnSM,2617
20
21
  core/backends.py,sha256=f8gb5D3_f8sM6UJop0aS8TAkrkh14NaP0JrqTcT_L3Y,2686
21
22
  core/checks.py,sha256=sRIA9tYAKXO6kJf96ADFrkYyvp47gYqv18Y12vukUqg,931
@@ -24,16 +25,18 @@ core/environment.py,sha256=QcOshpWNG0l_agW-b9efNvVFKqdatj6sUK8FT6p92gU,1238
24
25
  core/fields.py,sha256=uS5nDozL6IsTAbjg08VcOB8K85e0XROUYCBAOIfAcPE,2127
25
26
  core/lcd_screen.py,sha256=7iSg8OZMK8qHGM1FVOwbgsWCogEe5eR6mie-A5YQKY4,2649
26
27
  core/middleware.py,sha256=utMGESBrR-rHRV5_q1X2g2wnmpziaUcc9jGzoZwvqqQ,1258
27
- core/models.py,sha256=_Ng5pxlF2gTKzHd4si1t7r312sj--Zk3AOArCQ8BZbA,44518
28
+ core/models.py,sha256=RrWPU5JpLBXehN1vJvKMFyuRWu7ko25MVD_KS10g7EQ,47095
28
29
  core/notifications.py,sha256=w9rPCsZfZNSoBUH-1KVe9Yr8LvG9vtkgmzw3DKLcDoM,3787
29
30
  core/release.py,sha256=DzK44yr7vkAK_xzDeejVFwQnkE23yqWvmIWZHQZ1nwI,11003
30
31
  core/system.py,sha256=EVvxYxnmJ1Wn-XIkt8pk2E0Rr8poRozkZ218E3TNHCY,3504
31
32
  core/tasks.py,sha256=Q4QweRlhahbXYEeL9ytlyQWwWQFP63q-ZY_fN0EXwtg,3145
32
33
  core/test_system_info.py,sha256=AXo-HqTLMyOpsQIShDDzyl_nMHOyKbAIuNr9fYZzoKg,605
33
- core/tests.py,sha256=6dZ3CvAZ5fg7t5a11OsPktNfjRH-Fk8HDTPIRVhXWkg,26581
34
+ core/tests.py,sha256=kDdHDlsUJot4CucYZWM3THAtYqpvo1dQDUukqJVspCU,30281
34
35
  core/urls.py,sha256=pBkcWdiA0Aag7z4UOu7HT4Im4ghPPfBZKeogmoO0H5U,406
35
36
  core/user_data.py,sha256=pfi4fgHl0CosG9jkfe__bSNN-Knhnzp7UirnCT_tae8,12029
36
37
  core/views.py,sha256=-4k3YSv9RfsAY14aLXkngzjXT0yyXD90D6KOJ3cwSLQ,16137
38
+ core/workgroup_urls.py,sha256=1jo3Oay26lU45LYk5kMfU0667xRsA16rILK8DZmvO6U,331
39
+ core/workgroup_views.py,sha256=ku9i--ilN8o9fDzjo8WhqaIjMU3TqAN48w60k-WJHpg,1753
37
40
  nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
41
  nodes/actions.py,sha256=2QOtRdhm__4f0Pgy3n2tg9HCbS47ZK9qBdTXJ9YZJfA,2339
39
42
  nodes/admin.py,sha256=a-LAbgNG-0QHA8Og24fslyPyze_kPKhuUs3syAVbNr4,21824
@@ -47,7 +50,7 @@ nodes/utils.py,sha256=aVEHtoisHyhWe_Fy2KPiP24Fs0wELtQMyfs4JGYnkuY,2320
47
50
  nodes/views.py,sha256=LISm9jqIdyUwQm45PegGg8RvmGRZH1jvCTCC14owRdQ,8444
48
51
  ocpp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
52
  ocpp/admin.py,sha256=9A_uLUg7l9DiqJq1foy47oFLb7zPuBOJeS73p7l0M0k,11851
50
- ocpp/apps.py,sha256=Hja6uavSO4Srbp7kZmp0yGuhEq1abKBhH004QcUG90I,759
53
+ ocpp/apps.py,sha256=wFRuhGMuFi_6pqBZGNU0V18Xs4-b1ARLv4Bik8ezffY,868
51
54
  ocpp/consumers.py,sha256=ykAkAicHRmn_qfUTp6psNUFkDrOnzuJfnHca9vgJLQI,11768
52
55
  ocpp/evcs.py,sha256=ZQKLqN8yJ4IOdedVt1z_gz_2eXBPdHHPvgxU2NkWgpw,33333
53
56
  ocpp/models.py,sha256=5g6JDdaLtX07jbuuJAbwPi-Z58LuCR25XjJHh7oe8kk,10367
@@ -71,7 +74,7 @@ pages/tests.py,sha256=B3MX2du5aZ8NztR-e8pTbypXTqhA-_NHZMy6O_OuqWE,28022
71
74
  pages/urls.py,sha256=glhQExK2vVLzzvaRypWfmMdnggBrpjlxCaN1BUbC_MY,457
72
75
  pages/utils.py,sha256=bCcjku0mQhzgvTR46QwQgyc8YnSS7VumC6Qv968aCic,313
73
76
  pages/views.py,sha256=lkgx2X2AwOGa_URCRVCxju_z_uG1_xW43PUNmjOEibU,7993
74
- arthexis-0.1.7.dist-info/METADATA,sha256=UpY_iJF5Qcumd_YWTnqBZXZDVLl1lG8k4Chpf95Zeuo,4458
75
- arthexis-0.1.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
76
- arthexis-0.1.7.dist-info/top_level.txt,sha256=J2a2q8_BWrCZ8H2WFUNMBfO2jz8j2gax6zZh-_1QDac,29
77
- arthexis-0.1.7.dist-info/RECORD,,
77
+ arthexis-0.1.8.dist-info/METADATA,sha256=ZdIS51Qh3wyywvXkAoXyxCJLWCZmAUh-bhka8OohwRI,5383
78
+ arthexis-0.1.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
79
+ arthexis-0.1.8.dist-info/top_level.txt,sha256=J2a2q8_BWrCZ8H2WFUNMBfO2jz8j2gax6zZh-_1QDac,29
80
+ arthexis-0.1.8.dist-info/RECORD,,
config/settings.py CHANGED
@@ -85,6 +85,7 @@ ALLOWED_HOSTS = [
85
85
  "10.42.0.0/16",
86
86
  "192.168.0.0/16",
87
87
  "arthexis.com",
88
+ "www.arthexis.com",
88
89
  ]
89
90
 
90
91
 
config/urls.py CHANGED
@@ -13,12 +13,15 @@ from django.apps import apps
13
13
  from django.conf import settings
14
14
  from django.conf.urls.static import static
15
15
  from django.contrib import admin
16
+ from django.contrib.admin import autodiscover
16
17
  from django.urls import include, path
17
18
  from django.views.decorators.csrf import csrf_exempt
18
19
  from django.views.i18n import set_language
19
20
  from django.utils.translation import gettext_lazy as _
20
21
  from core import views as core_views
22
+ from core.admindocs import CommandsView
21
23
 
24
+ autodiscover()
22
25
  admin.site.site_header = _("Constellation")
23
26
  admin.site.site_title = _("Constellation")
24
27
 
@@ -61,6 +64,11 @@ def autodiscovered_urlpatterns():
61
64
 
62
65
 
63
66
  urlpatterns = [
67
+ path(
68
+ "admin/doc/commands/",
69
+ CommandsView.as_view(),
70
+ name="django-admindocs-commands",
71
+ ),
64
72
  path("admin/doc/", include("django.contrib.admindocs.urls")),
65
73
  path(
66
74
  "admin/core/releases/<int:pk>/<str:action>/",
@@ -69,6 +77,7 @@ urlpatterns = [
69
77
  ),
70
78
  path("admin/", admin.site.urls),
71
79
  path("i18n/setlang/", csrf_exempt(set_language), name="set_language"),
80
+ path("api/", include("core.workgroup_urls")),
72
81
  path("", include("pages.urls")),
73
82
  ]
74
83
 
core/admin.py CHANGED
@@ -3,7 +3,7 @@ from django.contrib import admin
3
3
  from django.contrib.admin.widgets import RelatedFieldWidgetWrapper
4
4
  from django.urls import path, reverse
5
5
  from django.shortcuts import redirect, render
6
- from django.http import JsonResponse, HttpResponseBase
6
+ from django.http import JsonResponse, HttpResponseBase, HttpResponseRedirect
7
7
  from django.template.response import TemplateResponse
8
8
  from django.views.decorators.csrf import csrf_exempt
9
9
  from django.core.exceptions import ValidationError
@@ -45,6 +45,7 @@ from .models import (
45
45
  ReleaseManager,
46
46
  SecurityGroup,
47
47
  InviteLead,
48
+ ChatProfile,
48
49
  )
49
50
  from .user_data import UserDatumAdminMixin
50
51
 
@@ -52,6 +53,30 @@ from .user_data import UserDatumAdminMixin
52
53
  admin.site.unregister(Group)
53
54
 
54
55
 
56
+ class WorkgroupReleaseManager(ReleaseManager):
57
+ class Meta:
58
+ proxy = True
59
+ app_label = "post_office"
60
+ verbose_name = ReleaseManager._meta.verbose_name
61
+ verbose_name_plural = ReleaseManager._meta.verbose_name_plural
62
+
63
+
64
+ class WorkgroupSecurityGroup(SecurityGroup):
65
+ class Meta:
66
+ proxy = True
67
+ app_label = "post_office"
68
+ verbose_name = SecurityGroup._meta.verbose_name
69
+ verbose_name_plural = SecurityGroup._meta.verbose_name_plural
70
+
71
+
72
+ class ExperienceReference(Reference):
73
+ class Meta:
74
+ proxy = True
75
+ app_label = "pages"
76
+ verbose_name = Reference._meta.verbose_name
77
+ verbose_name_plural = Reference._meta.verbose_name_plural
78
+
79
+
55
80
  class SaveBeforeChangeAction(DjangoObjectActions):
56
81
  def response_change(self, request, obj):
57
82
  action = request.POST.get("_action")
@@ -65,7 +90,7 @@ class SaveBeforeChangeAction(DjangoObjectActions):
65
90
  return super().response_change(request, obj)
66
91
 
67
92
 
68
- @admin.register(Reference)
93
+ @admin.register(ExperienceReference)
69
94
  class ReferenceAdmin(admin.ModelAdmin):
70
95
  list_display = (
71
96
  "alt_text",
@@ -141,14 +166,20 @@ class ReferenceAdmin(admin.ModelAdmin):
141
166
  qr_code.short_description = "QR Code"
142
167
 
143
168
 
144
- @admin.register(ReleaseManager)
169
+ @admin.register(WorkgroupReleaseManager)
145
170
  class ReleaseManagerAdmin(admin.ModelAdmin):
146
171
  list_display = ("user", "pypi_username", "pypi_url")
147
172
 
148
173
 
149
174
  @admin.register(Package)
150
175
  class PackageAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
151
- list_display = ("name", "description", "homepage_url", "release_manager")
176
+ list_display = (
177
+ "name",
178
+ "description",
179
+ "homepage_url",
180
+ "release_manager",
181
+ "is_active",
182
+ )
152
183
  actions = ["prepare_next_release"]
153
184
  change_actions = ["prepare_next_release_action"]
154
185
 
@@ -177,6 +208,24 @@ class PackageAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
177
208
  reverse("admin:core_packagerelease_change", args=[release.pk])
178
209
  )
179
210
 
211
+ def get_urls(self):
212
+ urls = super().get_urls()
213
+ custom = [
214
+ path(
215
+ "prepare-next-release/",
216
+ self.admin_site.admin_view(self.prepare_next_release_active),
217
+ name="core_package_prepare_next_release",
218
+ )
219
+ ]
220
+ return custom + urls
221
+
222
+ def prepare_next_release_active(self, request):
223
+ package = Package.objects.filter(is_active=True).first()
224
+ if not package:
225
+ self.message_user(request, "No active package", messages.ERROR)
226
+ return redirect("admin:core_package_changelist")
227
+ return self._prepare(request, package)
228
+
180
229
  @admin.action(description="Prepare next Release")
181
230
  def prepare_next_release(self, request, queryset):
182
231
  if queryset.count() != 1:
@@ -201,7 +250,7 @@ class SecurityGroupAdminForm(forms.ModelForm):
201
250
  )
202
251
 
203
252
  class Meta:
204
- model = SecurityGroup
253
+ model = WorkgroupSecurityGroup
205
254
  fields = "__all__"
206
255
 
207
256
  def __init__(self, *args, **kwargs):
@@ -219,7 +268,7 @@ class SecurityGroupAdminForm(forms.ModelForm):
219
268
  return instance
220
269
 
221
270
 
222
- @admin.register(SecurityGroup)
271
+ @admin.register(WorkgroupSecurityGroup)
223
272
  class SecurityGroupAdmin(DjangoGroupAdmin):
224
273
  form = SecurityGroupAdminForm
225
274
  fieldsets = ((None, {"fields": ("name", "parent", "users", "permissions")}),)
@@ -517,6 +566,46 @@ class EmailInboxAdmin(admin.ModelAdmin):
517
566
  return TemplateResponse(request, "admin/core/emailinbox/search.html", context)
518
567
 
519
568
 
569
+ class WorkgroupChatProfile(ChatProfile):
570
+ class Meta:
571
+ proxy = True
572
+ app_label = "post_office"
573
+ verbose_name = ChatProfile._meta.verbose_name
574
+ verbose_name_plural = ChatProfile._meta.verbose_name_plural
575
+
576
+
577
+ @admin.register(WorkgroupChatProfile)
578
+ class ChatProfileAdmin(admin.ModelAdmin):
579
+ list_display = ("user", "created_at", "last_used_at", "is_active")
580
+ readonly_fields = ("user_key_hash",)
581
+
582
+ change_form_template = "admin/workgroupchatprofile_change_form.html"
583
+
584
+ def get_urls(self):
585
+ urls = super().get_urls()
586
+ custom = [
587
+ path(
588
+ "<path:object_id>/generate-key/",
589
+ self.admin_site.admin_view(self.generate_key),
590
+ name="post_office_workgroupchatprofile_generate_key",
591
+ ),
592
+ ]
593
+ return custom + urls
594
+
595
+ def generate_key(self, request, object_id, *args, **kwargs):
596
+ profile = self.get_object(request, object_id)
597
+ if profile is None:
598
+ return HttpResponseRedirect("../")
599
+ profile, key = ChatProfile.issue_key(profile.user)
600
+ context = {
601
+ **self.admin_site.each_context(request),
602
+ "opts": self.model._meta,
603
+ "original": profile,
604
+ "user_key": key,
605
+ }
606
+ return TemplateResponse(request, "admin/chatprofile_key.html", context)
607
+
608
+
520
609
  class EnergyCreditInline(admin.TabularInline):
521
610
  model = EnergyCredit
522
611
  fields = ("amount_kw", "created_by", "created_on")
@@ -720,6 +809,7 @@ class RFIDForm(forms.ModelForm):
720
809
  super().__init__(*args, **kwargs)
721
810
  self.fields["reference"].required = False
722
811
  rel = RFID._meta.get_field("reference").remote_field
812
+ rel.model = ExperienceReference
723
813
  widget = self.fields["reference"].widget
724
814
  self.fields["reference"].widget = RelatedFieldWidgetWrapper(
725
815
  widget,
@@ -809,6 +899,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
809
899
  list_display_links = ("version",)
810
900
  actions = ["publish_release", "validate_releases"]
811
901
  change_actions = ["publish_release_action"]
902
+ changelist_actions = ["refresh_from_pypi"]
812
903
  readonly_fields = ("pypi_url", "is_current", "revision")
813
904
  fields = (
814
905
  "package",
@@ -829,6 +920,46 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, admin.ModelAdmin):
829
920
 
830
921
  revision_short.short_description = "revision"
831
922
 
923
+ def refresh_from_pypi(self, request, queryset):
924
+ package = Package.objects.filter(is_active=True).first()
925
+ if not package:
926
+ self.message_user(request, "No active package", messages.ERROR)
927
+ return
928
+ try:
929
+ resp = requests.get(
930
+ f"https://pypi.org/pypi/{package.name}/json", timeout=10
931
+ )
932
+ resp.raise_for_status()
933
+ except Exception as exc: # pragma: no cover - network failure
934
+ self.message_user(request, str(exc), messages.ERROR)
935
+ return
936
+ releases = resp.json().get("releases", {})
937
+ created = 0
938
+ for version in releases:
939
+ exists = PackageRelease.all_objects.filter(
940
+ package=package, version=version
941
+ ).exists()
942
+ if not exists:
943
+ PackageRelease.objects.create(
944
+ package=package,
945
+ release_manager=package.release_manager,
946
+ version=version,
947
+ pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
948
+ )
949
+ created += 1
950
+ if created:
951
+ PackageRelease.dump_fixture()
952
+ self.message_user(
953
+ request,
954
+ f"Created {created} release{'s' if created != 1 else ''} from PyPI",
955
+ messages.SUCCESS,
956
+ )
957
+ else:
958
+ self.message_user(request, "No new releases found", messages.INFO)
959
+
960
+ refresh_from_pypi.label = "Refresh from PyPI"
961
+ refresh_from_pypi.short_description = "Refresh from PyPI"
962
+
832
963
  def _publish_release(self, request, release):
833
964
  try:
834
965
  release.full_clean()
core/admindocs.py ADDED
@@ -0,0 +1,44 @@
1
+ import argparse
2
+ from django.core.management import get_commands, load_command_class
3
+ from django.contrib.admindocs.views import BaseAdminDocsView
4
+
5
+
6
+ class CommandsView(BaseAdminDocsView):
7
+ template_name = "admin_doc/commands.html"
8
+
9
+ def get_context_data(self, **kwargs):
10
+ commands = []
11
+ for name, app_name in sorted(get_commands().items()):
12
+ try:
13
+ cmd = load_command_class(app_name, name)
14
+ parser = cmd.create_parser("manage.py", name)
15
+ except Exception: # pragma: no cover - command import issues
16
+ continue
17
+ args = []
18
+ options = []
19
+ for action in parser._actions:
20
+ if isinstance(action, argparse._HelpAction):
21
+ continue
22
+ if action.option_strings:
23
+ options.append(
24
+ {
25
+ "opts": ", ".join(action.option_strings),
26
+ "help": action.help or "",
27
+ }
28
+ )
29
+ else:
30
+ args.append(
31
+ {
32
+ "name": action.metavar or action.dest,
33
+ "help": action.help or "",
34
+ }
35
+ )
36
+ commands.append(
37
+ {
38
+ "name": name,
39
+ "help": getattr(cmd, "help", ""),
40
+ "args": args,
41
+ "options": options,
42
+ }
43
+ )
44
+ return super().get_context_data(**{**kwargs, "commands": commands})
core/models.py CHANGED
@@ -17,6 +17,7 @@ from django.contrib.contenttypes.models import ContentType
17
17
  import hashlib
18
18
  import os
19
19
  import subprocess
20
+ import secrets
20
21
  from io import BytesIO
21
22
  from django.core.files.base import ContentFile
22
23
  import qrcode
@@ -1153,14 +1154,30 @@ class Package(Entity):
1153
1154
  release_manager = models.ForeignKey(
1154
1155
  ReleaseManager, on_delete=models.SET_NULL, null=True, blank=True
1155
1156
  )
1157
+ is_active = models.BooleanField(
1158
+ default=False,
1159
+ help_text="Designates the active package for version comparisons",
1160
+ )
1156
1161
 
1157
1162
  class Meta:
1158
1163
  verbose_name = "Package"
1159
1164
  verbose_name_plural = "Packages"
1165
+ constraints = [
1166
+ models.UniqueConstraint(
1167
+ fields=("is_active",),
1168
+ condition=models.Q(is_active=True),
1169
+ name="unique_active_package",
1170
+ )
1171
+ ]
1160
1172
 
1161
1173
  def __str__(self) -> str: # pragma: no cover - trivial
1162
1174
  return self.name
1163
1175
 
1176
+ def save(self, *args, **kwargs):
1177
+ if self.is_active:
1178
+ type(self).objects.exclude(pk=self.pk).update(is_active=False)
1179
+ super().save(*args, **kwargs)
1180
+
1164
1181
  def to_package(self) -> ReleasePackage:
1165
1182
  """Return a :class:`ReleasePackage` instance from package data."""
1166
1183
  return ReleasePackage(
@@ -1250,11 +1267,13 @@ class PackageRelease(Entity):
1250
1267
 
1251
1268
  @property
1252
1269
  def is_current(self) -> bool:
1253
- """Return ``True`` if this release matches the current revision."""
1254
- from utils import revision as revision_utils
1255
-
1256
- current = revision_utils.get_revision()
1257
- return bool(current) and current == self.revision
1270
+ """Return ``True`` when this release's version matches the VERSION file
1271
+ and its package is active."""
1272
+ version_path = Path("VERSION")
1273
+ if not version_path.exists():
1274
+ return False
1275
+ current_version = version_path.read_text().strip()
1276
+ return current_version == self.version and self.package.is_active
1258
1277
 
1259
1278
  @classmethod
1260
1279
  def latest(cls):
@@ -1316,3 +1335,54 @@ def _rfid_unique_energy_account(sender, instance, action, reverse, model, pk_set
1316
1335
  ).exclude(energy_accounts=instance)
1317
1336
  if conflict.exists():
1318
1337
  raise ValidationError("RFID tags may only be assigned to one energy account.")
1338
+
1339
+
1340
+ def hash_key(key: str) -> str:
1341
+ """Return a SHA-256 hash for ``key``."""
1342
+
1343
+ return hashlib.sha256(key.encode()).hexdigest()
1344
+
1345
+
1346
+ class ChatProfile(models.Model):
1347
+ """Stores a hashed user key used by the assistant for authentication.
1348
+
1349
+ The plain-text ``user_key`` is generated server-side and shown only once.
1350
+ Users must supply this key in the ``Authorization: Bearer <user_key>``
1351
+ header when requesting protected endpoints. Only the hash is stored.
1352
+ """
1353
+
1354
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
1355
+ user = models.OneToOneField(
1356
+ settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="chat_profile"
1357
+ )
1358
+ user_key_hash = models.CharField(max_length=64, unique=True)
1359
+ scopes = models.JSONField(default=list, blank=True)
1360
+ created_at = models.DateTimeField(auto_now_add=True)
1361
+ last_used_at = models.DateTimeField(null=True, blank=True)
1362
+ is_active = models.BooleanField(default=True)
1363
+
1364
+ class Meta:
1365
+ db_table = "workgroup_chatprofile"
1366
+ verbose_name = "Chat Profile"
1367
+ verbose_name_plural = "Chat Profiles"
1368
+
1369
+ @classmethod
1370
+ def issue_key(cls, user) -> tuple["ChatProfile", str]:
1371
+ """Create or update a profile and return it with a new plain key."""
1372
+
1373
+ key = secrets.token_hex(32)
1374
+ key_hash = hash_key(key)
1375
+ profile, _ = cls.objects.update_or_create(
1376
+ user=user,
1377
+ defaults={"user_key_hash": key_hash, "last_used_at": None, "is_active": True},
1378
+ )
1379
+ return profile, key
1380
+
1381
+ def touch(self) -> None:
1382
+ """Record that the key was used."""
1383
+
1384
+ self.last_used_at = timezone.now()
1385
+ self.save(update_fields=["last_used_at"])
1386
+
1387
+ def __str__(self) -> str: # pragma: no cover - simple representation
1388
+ return f"ChatProfile for {self.user}"
core/tests.py CHANGED
@@ -30,7 +30,7 @@ from .models import (
30
30
  PackageRelease,
31
31
  )
32
32
  from django.contrib.admin.sites import AdminSite
33
- from core.admin import PackageReleaseAdmin
33
+ from core.admin import PackageReleaseAdmin, PackageAdmin
34
34
  from ocpp.models import Transaction, Charger
35
35
 
36
36
  from django.core.exceptions import ValidationError
@@ -647,7 +647,8 @@ class ReleaseProcessTests(TestCase):
647
647
  self.client.get(f"{url}?step=0")
648
648
  self.assertEqual(run, ["step", "step"])
649
649
  self.client.get(f"{url}?restart=1")
650
- self.assertEqual(count_file.read_text(), "2")
650
+ # Restart counter resets after running a step
651
+ self.assertEqual(count_file.read_text(), "1")
651
652
 
652
653
 
653
654
  class PackageReleaseAdminActionTests(TestCase):
@@ -680,3 +681,94 @@ class PackageReleaseAdminActionTests(TestCase):
680
681
  self.assertEqual(PackageRelease.objects.count(), 1)
681
682
  dump.assert_not_called()
682
683
 
684
+ @mock.patch("core.admin.PackageRelease.dump_fixture")
685
+ @mock.patch("core.admin.requests.get")
686
+ def test_refresh_from_pypi_creates_releases(self, mock_get, dump):
687
+ mock_get.return_value.raise_for_status.return_value = None
688
+ mock_get.return_value.json.return_value = {
689
+ "releases": {"1.0.0": [], "1.1.0": []}
690
+ }
691
+ self.admin.refresh_from_pypi(
692
+ self.request, PackageRelease.objects.none()
693
+ )
694
+ self.assertTrue(
695
+ PackageRelease.objects.filter(version="1.1.0").exists()
696
+ )
697
+ dump.assert_called_once()
698
+
699
+
700
+ class PackageActiveTests(TestCase):
701
+ def test_only_one_active_package(self):
702
+ default = Package.objects.get(name="arthexis")
703
+ self.assertTrue(default.is_active)
704
+ other = Package.objects.create(name="pkg", is_active=True)
705
+ default.refresh_from_db()
706
+ other.refresh_from_db()
707
+ self.assertFalse(default.is_active)
708
+ self.assertTrue(other.is_active)
709
+
710
+
711
+ class PackageReleaseCurrentTests(TestCase):
712
+ def setUp(self):
713
+ self.package = Package.objects.get(name="arthexis")
714
+ self.version_path = Path("VERSION")
715
+ self.original = self.version_path.read_text()
716
+ self.version_path.write_text("1.0.0")
717
+ self.release = PackageRelease.objects.create(
718
+ package=self.package, version="1.0.0"
719
+ )
720
+
721
+ def tearDown(self):
722
+ self.version_path.write_text(self.original)
723
+
724
+ def test_is_current_true_when_version_matches_and_package_active(self):
725
+ self.assertTrue(self.release.is_current)
726
+
727
+ def test_is_current_false_when_package_inactive(self):
728
+ self.package.is_active = False
729
+ self.package.save()
730
+ self.assertFalse(self.release.is_current)
731
+
732
+ def test_is_current_false_when_version_differs(self):
733
+ self.release.version = "2.0.0"
734
+ self.release.save()
735
+ self.assertFalse(self.release.is_current)
736
+
737
+
738
+ class PackageAdminPrepareNextReleaseTests(TestCase):
739
+ def setUp(self):
740
+ self.factory = RequestFactory()
741
+ self.site = AdminSite()
742
+ self.admin = PackageAdmin(Package, self.site)
743
+ self.admin.message_user = lambda *args, **kwargs: None
744
+ self.package = Package.objects.get(name="arthexis")
745
+
746
+ def test_prepare_next_release_active_creates_release(self):
747
+ PackageRelease.all_objects.filter(package=self.package).delete()
748
+ request = self.factory.get("/admin/core/package/prepare-next-release/")
749
+ response = self.admin.prepare_next_release_active(request)
750
+ self.assertEqual(response.status_code, 302)
751
+ self.assertEqual(
752
+ PackageRelease.all_objects.filter(package=self.package).count(), 1
753
+ )
754
+
755
+
756
+ class PackageReleaseChangelistTests(TestCase):
757
+ def setUp(self):
758
+ self.client = Client()
759
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
760
+ self.client.force_login(User.objects.get(username="admin"))
761
+
762
+ def test_prepare_next_release_button_present(self):
763
+ response = self.client.get(reverse("admin:core_packagerelease_changelist"))
764
+ self.assertContains(
765
+ response, reverse("admin:core_package_prepare_next_release"), html=False
766
+ )
767
+
768
+ def test_refresh_from_pypi_button_present(self):
769
+ response = self.client.get(reverse("admin:core_packagerelease_changelist"))
770
+ refresh_url = reverse(
771
+ "admin:core_packagerelease_actions", args=["refresh_from_pypi"]
772
+ )
773
+ self.assertContains(response, refresh_url, html=False)
774
+
core/workgroup_urls.py ADDED
@@ -0,0 +1,13 @@
1
+ """URL routes for chat profile endpoints."""
2
+
3
+ from django.urls import path
4
+
5
+ from . import workgroup_views as views
6
+
7
+ app_name = "workgroup"
8
+
9
+ urlpatterns = [
10
+ path("chat-profiles/<int:user_id>/", views.issue_key, name="chatprofile-issue"),
11
+ path("assistant/test/", views.assistant_test, name="assistant-test"),
12
+ ]
13
+
@@ -0,0 +1,57 @@
1
+ """REST endpoints for ChatProfile issuance and authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import wraps
6
+
7
+ from django.contrib.auth import get_user_model
8
+ from django.http import HttpResponse, JsonResponse
9
+ from django.views.decorators.csrf import csrf_exempt
10
+ from django.views.decorators.http import require_GET, require_POST
11
+
12
+ from .models import ChatProfile, hash_key
13
+
14
+
15
+ @csrf_exempt
16
+ @require_POST
17
+ def issue_key(request, user_id: int) -> JsonResponse:
18
+ """Issue a new ``user_key`` for ``user_id``.
19
+
20
+ The response reveals the plain key once. Store only the hash server-side.
21
+ """
22
+
23
+ user = get_user_model().objects.get(pk=user_id)
24
+ profile, key = ChatProfile.issue_key(user)
25
+ return JsonResponse({"user_id": user_id, "user_key": key})
26
+
27
+
28
+ def authenticate(view_func):
29
+ """View decorator that validates the ``Authorization`` header."""
30
+
31
+ @wraps(view_func)
32
+ def wrapper(request, *args, **kwargs):
33
+ header = request.META.get("HTTP_AUTHORIZATION", "")
34
+ if not header.startswith("Bearer "):
35
+ return HttpResponse(status=401)
36
+
37
+ key_hash = hash_key(header.split(" ", 1)[1])
38
+ try:
39
+ profile = ChatProfile.objects.get(user_key_hash=key_hash, is_active=True)
40
+ except ChatProfile.DoesNotExist:
41
+ return HttpResponse(status=401)
42
+
43
+ profile.touch()
44
+ request.chat_profile = profile
45
+ return view_func(request, *args, **kwargs)
46
+
47
+ return wrapper
48
+
49
+
50
+ @require_GET
51
+ @authenticate
52
+ def assistant_test(request):
53
+ """Return a simple greeting to confirm authentication."""
54
+
55
+ user_id = request.chat_profile.user_id
56
+ return JsonResponse({"message": f"Hello from user {user_id}"})
57
+
ocpp/apps.py CHANGED
@@ -9,8 +9,9 @@ class OcppConfig(AppConfig):
9
9
  verbose_name = "3. Protocols"
10
10
 
11
11
  def ready(self): # pragma: no cover - startup side effects
12
- lock = Path(settings.BASE_DIR) / "locks" / "control.lck"
13
- if not lock.exists():
12
+ control_lock = Path(settings.BASE_DIR) / "locks" / "control.lck"
13
+ rfid_lock = Path(settings.BASE_DIR) / "locks" / "rfid.lck"
14
+ if not (control_lock.exists() and rfid_lock.exists()):
14
15
  return
15
16
  from .rfid.background_reader import start
16
17
  from .rfid.signals import tag_scanned