arthexis 0.1.9__py3-none-any.whl → 0.1.11__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 (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
core/admin.py CHANGED
@@ -1,7 +1,8 @@
1
1
  from django import forms
2
2
  from django.contrib import admin
3
3
  from django.contrib.admin.widgets import RelatedFieldWidgetWrapper
4
- from django.urls import path, reverse
4
+ from django.urls import NoReverseMatch, path, reverse
5
+ from urllib.parse import urlencode
5
6
  from django.shortcuts import redirect, render
6
7
  from django.http import JsonResponse, HttpResponseBase, HttpResponseRedirect
7
8
  from django.template.response import TemplateResponse
@@ -20,6 +21,8 @@ from import_export.admin import ImportExportModelAdmin
20
21
  from import_export.widgets import ForeignKeyWidget
21
22
  from django.contrib.auth.models import Group
22
23
  from django.templatetags.static import static
24
+ from django.utils import timezone
25
+ from django.utils.dateparse import parse_datetime
23
26
  from django.utils.html import format_html
24
27
  from django.utils.translation import gettext_lazy as _
25
28
  from django.forms.models import BaseInlineFormSet
@@ -50,6 +53,7 @@ from .models import (
50
53
  Reference,
51
54
  OdooProfile,
52
55
  EmailInbox,
56
+ SocialProfile,
53
57
  EmailCollector,
54
58
  Package,
55
59
  PackageRelease,
@@ -61,7 +65,15 @@ from .models import (
61
65
  Todo,
62
66
  hash_key,
63
67
  )
64
- from .user_data import EntityModelAdmin, delete_user_fixture, dump_user_fixture
68
+ from .user_data import (
69
+ EntityModelAdmin,
70
+ UserDatumAdminMixin,
71
+ delete_user_fixture,
72
+ dump_user_fixture,
73
+ _fixture_path,
74
+ _resolve_fixture_user,
75
+ _user_allows_user_data,
76
+ )
65
77
  from .widgets import OdooProductWidget
66
78
  from .mcp import process as mcp_process
67
79
 
@@ -161,11 +173,98 @@ class SaveBeforeChangeAction(DjangoObjectActions):
161
173
  return super().response_change(request, obj)
162
174
 
163
175
 
176
+ class ProfileAdminMixin:
177
+ """Reusable actions for profile-bound admin classes."""
178
+
179
+ def _resolve_my_profile_target(self, request):
180
+ opts = self.model._meta
181
+ changelist_url = reverse(
182
+ f"admin:{opts.app_label}_{opts.model_name}_changelist"
183
+ )
184
+ user = getattr(request, "user", None)
185
+ if not getattr(user, "is_authenticated", False):
186
+ return (
187
+ changelist_url,
188
+ _("You must be logged in to manage your profile."),
189
+ messages.ERROR,
190
+ )
191
+
192
+ profile = self.model._default_manager.filter(user=user).first()
193
+ if profile is not None:
194
+ permission_check = getattr(self, "has_view_or_change_permission", None)
195
+ has_permission = (
196
+ permission_check(request, obj=profile)
197
+ if callable(permission_check)
198
+ else self.has_change_permission(request, obj=profile)
199
+ )
200
+ if has_permission:
201
+ change_url = reverse(
202
+ f"admin:{opts.app_label}_{opts.model_name}_change",
203
+ args=[profile.pk],
204
+ )
205
+ return change_url, None, None
206
+ return (
207
+ changelist_url,
208
+ _("You do not have permission to view this profile."),
209
+ messages.ERROR,
210
+ )
211
+
212
+ if self.has_add_permission(request):
213
+ add_url = reverse(f"admin:{opts.app_label}_{opts.model_name}_add")
214
+ params = {}
215
+ user_id = getattr(user, "pk", None)
216
+ if user_id:
217
+ params["user"] = user_id
218
+ if params:
219
+ add_url = f"{add_url}?{urlencode(params)}"
220
+ return add_url, None, None
221
+
222
+ return (
223
+ changelist_url,
224
+ _("You do not have permission to create this profile."),
225
+ messages.ERROR,
226
+ )
227
+
228
+ def get_my_profile_url(self, request):
229
+ url, _message, _level = self._resolve_my_profile_target(request)
230
+ return url
231
+
232
+ def _redirect_to_my_profile(self, request):
233
+ target_url, message, level = self._resolve_my_profile_target(request)
234
+ if message:
235
+ self.message_user(request, message, level=level)
236
+ return HttpResponseRedirect(target_url)
237
+
238
+ @admin.action(description=_("Active Profile"))
239
+ def my_profile(self, request, queryset=None):
240
+ return self._redirect_to_my_profile(request)
241
+
242
+ def my_profile_action(self, request, obj=None):
243
+ return self._redirect_to_my_profile(request)
244
+
245
+ my_profile_action.label = _("Active Profile")
246
+ my_profile_action.short_description = _("Active Profile")
247
+
248
+ def get_actions(self, request):
249
+ actions = super().get_actions(request)
250
+ if "my_profile" not in actions:
251
+ action = getattr(self, "my_profile", None)
252
+ if action is not None:
253
+ actions["my_profile"] = (
254
+ action,
255
+ "my_profile",
256
+ getattr(action, "short_description", _("Active Profile")),
257
+ )
258
+ return actions
259
+
260
+
164
261
  @admin.register(ExperienceReference)
165
262
  class ReferenceAdmin(EntityModelAdmin):
166
263
  list_display = (
167
264
  "alt_text",
168
265
  "content_type",
266
+ "link",
267
+ "header",
169
268
  "footer",
170
269
  "visibility",
171
270
  "author",
@@ -182,6 +281,7 @@ class ReferenceAdmin(EntityModelAdmin):
182
281
  "features",
183
282
  "sites",
184
283
  "include_in_footer",
284
+ "show_in_header",
185
285
  "footer_visibility",
186
286
  "transaction_uuid",
187
287
  "author",
@@ -200,10 +300,23 @@ class ReferenceAdmin(EntityModelAdmin):
200
300
  def footer(self, obj):
201
301
  return obj.include_in_footer
202
302
 
303
+ @admin.display(description="Header", boolean=True, ordering="show_in_header")
304
+ def header(self, obj):
305
+ return obj.show_in_header
306
+
203
307
  @admin.display(description="Visibility", ordering="footer_visibility")
204
308
  def visibility(self, obj):
205
309
  return obj.get_footer_visibility_display()
206
310
 
311
+ @admin.display(description="LINK")
312
+ def link(self, obj):
313
+ if obj.value:
314
+ return format_html(
315
+ '<a href="{}" target="_blank" rel="noopener noreferrer">open</a>',
316
+ obj.value,
317
+ )
318
+ return ""
319
+
207
320
  def get_urls(self):
208
321
  urls = super().get_urls()
209
322
  custom = [
@@ -260,11 +373,12 @@ class ReleaseManagerAdminForm(forms.ModelForm):
260
373
 
261
374
 
262
375
  @admin.register(ReleaseManager)
263
- class ReleaseManagerAdmin(SaveBeforeChangeAction, EntityModelAdmin):
376
+ class ReleaseManagerAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
264
377
  form = ReleaseManagerAdminForm
265
378
  list_display = ("owner", "pypi_username", "pypi_url")
266
379
  actions = ["test_credentials"]
267
- change_actions = ["test_credentials_action"]
380
+ change_actions = ["test_credentials_action", "my_profile_action"]
381
+ changelist_actions = ["my_profile"]
268
382
  fieldsets = (
269
383
  ("Owner", {"fields": ("user", "group")}),
270
384
  (
@@ -325,9 +439,6 @@ class ReleaseManagerAdmin(SaveBeforeChangeAction, EntityModelAdmin):
325
439
  request, f"{manager} credentials check failed: {exc}", messages.ERROR
326
440
  )
327
441
 
328
- def get_model_perms(self, request):
329
- return {}
330
-
331
442
 
332
443
  @admin.register(Package)
333
444
  class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
@@ -338,7 +449,6 @@ class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
338
449
  "release_manager",
339
450
  "is_active",
340
451
  )
341
- actions = ["prepare_next_release"]
342
452
  change_actions = ["prepare_next_release_action"]
343
453
 
344
454
  def _prepare(self, request, package):
@@ -395,13 +505,6 @@ class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
395
505
  return redirect("admin:core_package_changelist")
396
506
  return self._prepare(request, package)
397
507
 
398
- @admin.action(description="Prepare next Release")
399
- def prepare_next_release(self, request, queryset):
400
- if queryset.count() != 1:
401
- self.message_user(request, "Select exactly one package", messages.ERROR)
402
- return
403
- return self._prepare(request, queryset.first())
404
-
405
508
  def prepare_next_release_action(self, request, obj):
406
509
  return self._prepare(request, obj)
407
510
 
@@ -442,7 +545,14 @@ class SecurityGroupAdmin(DjangoGroupAdmin):
442
545
 
443
546
 
444
547
  class InviteLeadAdmin(EntityModelAdmin):
445
- list_display = ("email", "mac_address", "created_on", "sent_on", "short_error")
548
+ list_display = (
549
+ "email",
550
+ "mac_address",
551
+ "created_on",
552
+ "sent_on",
553
+ "sent_via_outbox",
554
+ "short_error",
555
+ )
446
556
  search_fields = ("email", "comment")
447
557
  readonly_fields = (
448
558
  "created_on",
@@ -453,6 +563,7 @@ class InviteLeadAdmin(EntityModelAdmin):
453
563
  "ip_address",
454
564
  "mac_address",
455
565
  "sent_on",
566
+ "sent_via_outbox",
456
567
  "error",
457
568
  )
458
569
 
@@ -617,6 +728,17 @@ class EmailInboxAdminForm(forms.ModelForm):
617
728
  class ProfileInlineFormSet(BaseInlineFormSet):
618
729
  """Hide deletion controls and allow implicit removal when empty."""
619
730
 
731
+ @classmethod
732
+ def get_default_prefix(cls):
733
+ prefix = super().get_default_prefix()
734
+ if prefix:
735
+ return prefix
736
+ model_name = cls.model._meta.model_name
737
+ remote_field = getattr(cls.fk, "remote_field", None)
738
+ if remote_field is not None and getattr(remote_field, "one_to_one", False):
739
+ return model_name
740
+ return f"{model_name}_set"
741
+
620
742
  def add_fields(self, form, index):
621
743
  super().add_fields(form, index)
622
744
  if "DELETE" in form.fields:
@@ -750,6 +872,14 @@ class EmailInboxInlineForm(ProfileFormMixin, EmailInboxAdminForm):
750
872
  exclude = ("user", "group")
751
873
 
752
874
 
875
+ class SocialProfileInlineForm(ProfileFormMixin, forms.ModelForm):
876
+ profile_fields = SocialProfile.profile_fields
877
+
878
+ class Meta:
879
+ model = SocialProfile
880
+ fields = ("network", "handle", "domain", "did")
881
+
882
+
753
883
  class EmailOutboxInlineForm(ProfileFormMixin, forms.ModelForm):
754
884
  profile_fields = EmailOutbox.profile_fields
755
885
  password = forms.CharField(
@@ -768,6 +898,7 @@ class EmailOutboxInlineForm(ProfileFormMixin, forms.ModelForm):
768
898
  "use_tls",
769
899
  "use_ssl",
770
900
  "from_email",
901
+ "is_enabled",
771
902
  )
772
903
 
773
904
  def __init__(self, *args, **kwargs):
@@ -897,6 +1028,22 @@ PROFILE_INLINE_CONFIG = {
897
1028
  "from_email",
898
1029
  ),
899
1030
  },
1031
+ SocialProfile: {
1032
+ "form": SocialProfileInlineForm,
1033
+ "fieldsets": (
1034
+ (
1035
+ _("Configuration: Bluesky"),
1036
+ {
1037
+ "fields": ("network", "handle", "domain", "did"),
1038
+ "description": _(
1039
+ "1. Set your Bluesky handle to the domain managed by Arthexis. "
1040
+ "2. Publish a _atproto TXT record or /.well-known/atproto-did file pointing to the DID below. "
1041
+ "3. Save once Bluesky confirms the domain matches the DID."
1042
+ ),
1043
+ },
1044
+ ),
1045
+ ),
1046
+ },
900
1047
  ReleaseManager: {
901
1048
  "form": ReleaseManagerInlineForm,
902
1049
  "fields": (
@@ -955,6 +1102,7 @@ PROFILE_MODELS = (
955
1102
  OdooProfile,
956
1103
  EmailInbox,
957
1104
  EmailOutbox,
1105
+ SocialProfile,
958
1106
  ReleaseManager,
959
1107
  AssistantProfile,
960
1108
  )
@@ -974,21 +1122,61 @@ class UserPhoneNumberInline(admin.TabularInline):
974
1122
  fields = ("number", "priority")
975
1123
 
976
1124
 
977
- class UserAdmin(DjangoUserAdmin):
1125
+ class UserAdmin(UserDatumAdminMixin, DjangoUserAdmin):
978
1126
  fieldsets = _append_operate_as(DjangoUserAdmin.fieldsets)
979
1127
  add_fieldsets = _append_operate_as(DjangoUserAdmin.add_fieldsets)
980
1128
  inlines = USER_PROFILE_INLINES + [UserPhoneNumberInline]
981
1129
  change_form_template = "admin/user_profile_change_form.html"
1130
+ _skip_entity_user_datum = True
1131
+
1132
+ def _get_operate_as_profile_template(self):
1133
+ opts = self.model._meta
1134
+ try:
1135
+ return reverse(
1136
+ f"{self.admin_site.name}:{opts.app_label}_{opts.model_name}_change",
1137
+ args=["__ID__"],
1138
+ )
1139
+ except NoReverseMatch:
1140
+ user_opts = User._meta
1141
+ try:
1142
+ return reverse(
1143
+ f"{self.admin_site.name}:{user_opts.app_label}_{user_opts.model_name}_change",
1144
+ args=["__ID__"],
1145
+ )
1146
+ except NoReverseMatch:
1147
+ return None
982
1148
 
983
1149
  def render_change_form(
984
1150
  self, request, context, add=False, change=False, form_url="", obj=None
985
1151
  ):
986
- context = super().render_change_form(
1152
+ response = super().render_change_form(
987
1153
  request, context, add=add, change=change, form_url=form_url, obj=obj
988
1154
  )
989
- context["show_user_datum"] = False
990
- context["show_seed_datum"] = False
991
- return context
1155
+ if isinstance(response, dict):
1156
+ context_data = response
1157
+ else:
1158
+ context_data = getattr(response, "context_data", None)
1159
+ if context_data is not None:
1160
+ context_data["show_user_datum"] = False
1161
+ context_data["show_seed_datum"] = False
1162
+ context_data["show_save_as_copy"] = False
1163
+ operate_as_user = None
1164
+ operate_as_template = self._get_operate_as_profile_template()
1165
+ operate_as_url = None
1166
+ if obj and getattr(obj, "operate_as_id", None):
1167
+ try:
1168
+ operate_as_user = obj.operate_as
1169
+ except User.DoesNotExist:
1170
+ operate_as_user = None
1171
+ if operate_as_user and operate_as_template:
1172
+ operate_as_url = operate_as_template.replace(
1173
+ "__ID__", str(operate_as_user.pk)
1174
+ )
1175
+ if context_data is not None:
1176
+ context_data["operate_as_user"] = operate_as_user
1177
+ context_data["operate_as_profile_url_template"] = operate_as_template
1178
+ context_data["operate_as_profile_url"] = operate_as_url
1179
+ return response
992
1180
 
993
1181
  def get_inline_instances(self, request, obj=None):
994
1182
  inline_instances = super().get_inline_instances(request, obj)
@@ -1035,6 +1223,25 @@ class UserAdmin(DjangoUserAdmin):
1035
1223
  should_store = bool(inline_form.cleaned_data.get("user_datum"))
1036
1224
  self._update_profile_fixture(instance, owner_user, store=should_store)
1037
1225
 
1226
+ def save_model(self, request, obj, form, change):
1227
+ super().save_model(request, obj, form, change)
1228
+ if not getattr(obj, "pk", None):
1229
+ return
1230
+ target_user = _resolve_fixture_user(obj, obj)
1231
+ allow_user_data = _user_allows_user_data(target_user)
1232
+ if request.POST.get("_user_datum") == "on":
1233
+ type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
1234
+ obj.is_user_data = False
1235
+ delete_user_fixture(obj, target_user)
1236
+ self.message_user(
1237
+ request,
1238
+ _("User data for user accounts is managed through the profile sections."),
1239
+ )
1240
+ elif obj.is_user_data:
1241
+ type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
1242
+ obj.is_user_data = False
1243
+ delete_user_fixture(obj, target_user)
1244
+
1038
1245
 
1039
1246
  class EmailCollectorInline(admin.TabularInline):
1040
1247
  model = EmailCollector
@@ -1042,21 +1249,85 @@ class EmailCollectorInline(admin.TabularInline):
1042
1249
 
1043
1250
 
1044
1251
  class EmailCollectorAdmin(EntityModelAdmin):
1045
- list_display = ("inbox", "subject", "sender", "body", "fragment")
1046
- search_fields = ("subject", "sender", "body", "fragment")
1252
+ list_display = ("name", "inbox", "subject", "sender", "body", "fragment")
1253
+ search_fields = ("name", "subject", "sender", "body", "fragment")
1254
+ actions = ["preview_messages"]
1255
+
1256
+ @admin.action(description=_("Preview matches"))
1257
+ def preview_messages(self, request, queryset):
1258
+ results = []
1259
+ for collector in queryset.select_related("inbox"):
1260
+ try:
1261
+ messages = collector.search_messages(limit=5)
1262
+ error = None
1263
+ except ValidationError as exc:
1264
+ messages = []
1265
+ error = str(exc)
1266
+ except Exception as exc: # pragma: no cover - admin feedback
1267
+ messages = []
1268
+ error = str(exc)
1269
+ results.append(
1270
+ {
1271
+ "collector": collector,
1272
+ "messages": messages,
1273
+ "error": error,
1274
+ }
1275
+ )
1276
+ context = {
1277
+ "title": _("Preview Email Collectors"),
1278
+ "results": results,
1279
+ "opts": self.model._meta,
1280
+ "queryset": queryset,
1281
+ }
1282
+ return TemplateResponse(
1283
+ request, "admin/core/emailcollector/preview.html", context
1284
+ )
1285
+
1286
+
1287
+ @admin.register(SocialProfile)
1288
+ class SocialProfileAdmin(
1289
+ ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
1290
+ ):
1291
+ list_display = ("owner", "network", "handle", "domain")
1292
+ list_filter = ("network",)
1293
+ search_fields = ("handle", "domain", "did")
1294
+ changelist_actions = ["my_profile"]
1295
+ change_actions = ["my_profile_action"]
1296
+ fieldsets = (
1297
+ (_("Owner"), {"fields": ("user", "group")}),
1298
+ (
1299
+ _("Configuration: Bluesky"),
1300
+ {
1301
+ "fields": ("network", "handle", "domain", "did"),
1302
+ "description": _(
1303
+ "Link Arthexis to Bluesky by using a verified domain handle. "
1304
+ "Publish a _atproto TXT record or /.well-known/atproto-did file "
1305
+ "that returns the DID stored here before saving."
1306
+ ),
1307
+ },
1308
+ ),
1309
+ )
1310
+
1311
+ @admin.display(description=_("Owner"))
1312
+ def owner(self, obj):
1313
+ return obj.owner_display()
1047
1314
 
1048
1315
 
1049
1316
  @admin.register(OdooProfile)
1050
- class OdooProfileAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1317
+ class OdooProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
1051
1318
  change_form_template = "django_object_actions/change_form.html"
1052
1319
  form = OdooProfileAdminForm
1053
1320
  list_display = ("owner", "host", "database", "verified_on")
1054
1321
  readonly_fields = ("verified_on", "odoo_uid", "name", "email")
1055
1322
  actions = ["verify_credentials"]
1056
- change_actions = ["verify_credentials_action"]
1323
+ change_actions = ["verify_credentials_action", "my_profile_action"]
1324
+ changelist_actions = ["my_profile"]
1057
1325
  fieldsets = (
1058
1326
  ("Owner", {"fields": ("user", "group")}),
1059
- (None, {"fields": ("host", "database", "username", "password")}),
1327
+ (
1328
+ "Configuration",
1329
+ {"fields": ("host", "database", "username", "password")},
1330
+ ),
1060
1331
  (
1061
1332
  "Odoo Employee",
1062
1333
  {"fields": ("verified_on", "odoo_uid", "name", "email")},
@@ -1088,9 +1359,6 @@ class OdooProfileAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1088
1359
  verify_credentials_action.label = "Test credentials"
1089
1360
  verify_credentials_action.short_description = "Test credentials"
1090
1361
 
1091
- def get_model_perms(self, request):
1092
- return {}
1093
-
1094
1362
 
1095
1363
  class EmailSearchForm(forms.Form):
1096
1364
  subject = forms.CharField(
@@ -1107,11 +1375,12 @@ class EmailSearchForm(forms.Form):
1107
1375
  )
1108
1376
 
1109
1377
 
1110
- class EmailInboxAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1378
+ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
1111
1379
  form = EmailInboxAdminForm
1112
1380
  list_display = ("owner_label", "username", "host", "protocol")
1113
1381
  actions = ["test_connection", "search_inbox", "test_collectors"]
1114
- change_actions = ["test_collectors_action"]
1382
+ change_actions = ["test_collectors_action", "my_profile_action"]
1383
+ changelist_actions = ["my_profile"]
1115
1384
  change_form_template = "admin/core/emailinbox/change_form.html"
1116
1385
  inlines = [EmailCollectorInline]
1117
1386
 
@@ -1221,6 +1490,7 @@ class EmailInboxAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1221
1490
  subject=form.cleaned_data["subject"],
1222
1491
  from_address=form.cleaned_data["from_address"],
1223
1492
  body=form.cleaned_data["body"],
1493
+ use_regular_expressions=False,
1224
1494
  )
1225
1495
  context = {
1226
1496
  "form": form,
@@ -1244,12 +1514,16 @@ class EmailInboxAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1244
1514
 
1245
1515
 
1246
1516
  @admin.register(AssistantProfile)
1247
- class AssistantProfileAdmin(EntityModelAdmin):
1517
+ class AssistantProfileAdmin(
1518
+ ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
1519
+ ):
1248
1520
  list_display = ("owner", "created_at", "last_used_at", "is_active")
1249
1521
  readonly_fields = ("user_key_hash", "created_at", "last_used_at")
1250
1522
 
1251
1523
  change_form_template = "admin/workgroupassistantprofile_change_form.html"
1252
1524
  change_list_template = "admin/assistantprofile_change_list.html"
1525
+ change_actions = ["my_profile_action"]
1526
+ changelist_actions = ["my_profile"]
1253
1527
  fieldsets = (
1254
1528
  ("Owner", {"fields": ("user", "group")}),
1255
1529
  (
@@ -1406,9 +1680,6 @@ class AssistantProfileAdmin(EntityModelAdmin):
1406
1680
  self.message_user(request, msg, level=level)
1407
1681
  return self._redirect_to_changelist()
1408
1682
 
1409
- def get_model_perms(self, request):
1410
- return {}
1411
-
1412
1683
 
1413
1684
  class EnergyCreditInline(admin.TabularInline):
1414
1685
  model = EnergyCredit
@@ -1614,9 +1885,192 @@ class ProductAdminForm(forms.ModelForm):
1614
1885
  widgets = {"odoo_product": OdooProductWidget}
1615
1886
 
1616
1887
 
1888
+ class ProductFetchWizardForm(forms.Form):
1889
+ name = forms.CharField(label="Name", required=False)
1890
+ default_code = forms.CharField(label="Internal reference", required=False)
1891
+ barcode = forms.CharField(label="Barcode", required=False)
1892
+ renewal_period = forms.IntegerField(
1893
+ label="Renewal period (days)", min_value=1, initial=30
1894
+ )
1895
+
1896
+ def __init__(self, *args, require_search_terms=True, **kwargs):
1897
+ self.require_search_terms = require_search_terms
1898
+ super().__init__(*args, **kwargs)
1899
+
1900
+ def clean(self):
1901
+ cleaned = super().clean()
1902
+ if self.require_search_terms:
1903
+ if not any(
1904
+ cleaned.get(field) for field in ("name", "default_code", "barcode")
1905
+ ):
1906
+ raise forms.ValidationError(
1907
+ _("Enter at least one field to search for a product.")
1908
+ )
1909
+ return cleaned
1910
+
1911
+ def build_domain(self):
1912
+ domain = []
1913
+ if self.cleaned_data.get("name"):
1914
+ domain.append(("name", "ilike", self.cleaned_data["name"]))
1915
+ if self.cleaned_data.get("default_code"):
1916
+ domain.append(("default_code", "ilike", self.cleaned_data["default_code"]))
1917
+ if self.cleaned_data.get("barcode"):
1918
+ domain.append(("barcode", "ilike", self.cleaned_data["barcode"]))
1919
+ return domain
1920
+
1921
+
1617
1922
  @admin.register(Product)
1618
1923
  class ProductAdmin(EntityModelAdmin):
1619
1924
  form = ProductAdminForm
1925
+ actions = ["fetch_odoo_product"]
1926
+
1927
+ def _odoo_profile_admin(self):
1928
+ return self.admin_site._registry.get(OdooProfile)
1929
+
1930
+ def _search_odoo_products(self, profile, form):
1931
+ domain = form.build_domain()
1932
+ return profile.execute(
1933
+ "product.product",
1934
+ "search_read",
1935
+ domain,
1936
+ {
1937
+ "fields": [
1938
+ "name",
1939
+ "default_code",
1940
+ "barcode",
1941
+ "description_sale",
1942
+ ],
1943
+ "limit": 50,
1944
+ },
1945
+ )
1946
+
1947
+ @admin.action(description="Fetch Odoo Product")
1948
+ def fetch_odoo_product(self, request, queryset):
1949
+ profile = getattr(request.user, "odoo_profile", None)
1950
+ has_credentials = bool(profile and profile.is_verified)
1951
+ profile_admin = self._odoo_profile_admin()
1952
+ profile_url = None
1953
+ if profile_admin is not None:
1954
+ profile_url = profile_admin.get_my_profile_url(request)
1955
+
1956
+ context = {
1957
+ "opts": self.model._meta,
1958
+ "queryset": queryset,
1959
+ "action": "fetch_odoo_product",
1960
+ "has_credentials": has_credentials,
1961
+ "profile_url": profile_url,
1962
+ }
1963
+
1964
+ if not has_credentials:
1965
+ context["credential_error"] = _(
1966
+ "Configure your Odoo employee credentials before fetching products."
1967
+ )
1968
+ return TemplateResponse(
1969
+ request, "admin/core/product/fetch_odoo.html", context
1970
+ )
1971
+
1972
+ is_import = "import" in request.POST
1973
+ form_kwargs = {"require_search_terms": not is_import}
1974
+ if request.method == "POST":
1975
+ form = ProductFetchWizardForm(request.POST, **form_kwargs)
1976
+ else:
1977
+ form = ProductFetchWizardForm()
1978
+
1979
+ results = None
1980
+ selected_product_id = request.POST.get("product_id", "")
1981
+
1982
+ if request.method == "POST" and form.is_valid():
1983
+ try:
1984
+ results = self._search_odoo_products(profile, form)
1985
+ except Exception:
1986
+ form.add_error(None, _("Unable to fetch products from Odoo."))
1987
+ results = []
1988
+ else:
1989
+ if is_import:
1990
+ if not self.has_add_permission(request):
1991
+ form.add_error(
1992
+ None, _("You do not have permission to add products.")
1993
+ )
1994
+ else:
1995
+ product_id = request.POST.get("product_id")
1996
+ if not product_id:
1997
+ form.add_error(None, _("Select a product to import."))
1998
+ else:
1999
+ try:
2000
+ odoo_id = int(product_id)
2001
+ except (TypeError, ValueError):
2002
+ form.add_error(None, _("Invalid product selection."))
2003
+ else:
2004
+ match = next(
2005
+ (item for item in results if item.get("id") == odoo_id),
2006
+ None,
2007
+ )
2008
+ if not match:
2009
+ form.add_error(
2010
+ None,
2011
+ _(
2012
+ "The selected product was not found. Run the search again."
2013
+ ),
2014
+ )
2015
+ else:
2016
+ existing = self.model.objects.filter(
2017
+ odoo_product__id=odoo_id
2018
+ ).first()
2019
+ if existing:
2020
+ self.message_user(
2021
+ request,
2022
+ _(
2023
+ "Product %(name)s already imported; opening existing record."
2024
+ )
2025
+ % {"name": existing.name},
2026
+ level=messages.WARNING,
2027
+ )
2028
+ return HttpResponseRedirect(
2029
+ reverse(
2030
+ "admin:%s_%s_change"
2031
+ % (
2032
+ existing._meta.app_label,
2033
+ existing._meta.model_name,
2034
+ ),
2035
+ args=[existing.pk],
2036
+ )
2037
+ )
2038
+ product = self.model.objects.create(
2039
+ name=match.get("name") or f"Odoo Product {odoo_id}",
2040
+ description=match.get("description_sale", "") or "",
2041
+ renewal_period=form.cleaned_data["renewal_period"],
2042
+ odoo_product={
2043
+ "id": odoo_id,
2044
+ "name": match.get("name", ""),
2045
+ },
2046
+ )
2047
+ self.log_addition(
2048
+ request, product, "Imported product from Odoo"
2049
+ )
2050
+ self.message_user(
2051
+ request,
2052
+ _("Imported %(name)s from Odoo.")
2053
+ % {"name": product.name},
2054
+ )
2055
+ return HttpResponseRedirect(
2056
+ reverse(
2057
+ "admin:%s_%s_change"
2058
+ % (
2059
+ product._meta.app_label,
2060
+ product._meta.model_name,
2061
+ ),
2062
+ args=[product.pk],
2063
+ )
2064
+ )
2065
+ context.update(
2066
+ {
2067
+ "form": form,
2068
+ "results": results,
2069
+ "selected_product_id": selected_product_id,
2070
+ }
2071
+ )
2072
+ context["media"] = self.media + form.media
2073
+ return TemplateResponse(request, "admin/core/product/fetch_odoo.html", context)
1620
2074
 
1621
2075
 
1622
2076
  class RFIDResource(resources.ModelResource):
@@ -1756,30 +2210,39 @@ class ClientReportAdmin(EntityModelAdmin):
1756
2210
  ]
1757
2211
  RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
1758
2212
  period = forms.ChoiceField(
1759
- choices=PERIOD_CHOICES, widget=forms.RadioSelect, initial="range"
2213
+ choices=PERIOD_CHOICES,
2214
+ widget=forms.RadioSelect,
2215
+ initial="range",
2216
+ help_text="Choose how the reporting window will be calculated.",
1760
2217
  )
1761
2218
  start = forms.DateField(
1762
2219
  label="Start date",
1763
2220
  required=False,
1764
2221
  widget=forms.DateInput(attrs={"type": "date"}),
2222
+ help_text="First day included when using a custom date range.",
1765
2223
  )
1766
2224
  end = forms.DateField(
1767
2225
  label="End date",
1768
2226
  required=False,
1769
2227
  widget=forms.DateInput(attrs={"type": "date"}),
2228
+ help_text="Last day included when using a custom date range.",
1770
2229
  )
1771
2230
  week = forms.CharField(
1772
2231
  label="Week",
1773
2232
  required=False,
1774
2233
  widget=forms.TextInput(attrs={"type": "week"}),
2234
+ help_text="Generates the report for the ISO week that you select.",
1775
2235
  )
1776
2236
  month = forms.DateField(
1777
2237
  label="Month",
1778
2238
  required=False,
1779
2239
  widget=forms.DateInput(attrs={"type": "month"}),
2240
+ help_text="Generates the report for the calendar month that you select.",
1780
2241
  )
1781
2242
  owner = forms.ModelChoiceField(
1782
- queryset=get_user_model().objects.all(), required=False
2243
+ queryset=get_user_model().objects.all(),
2244
+ required=False,
2245
+ help_text="Sets who owns the report schedule and is listed as the requestor.",
1783
2246
  )
1784
2247
  destinations = forms.CharField(
1785
2248
  label="Email destinations",
@@ -1791,6 +2254,7 @@ class ClientReportAdmin(EntityModelAdmin):
1791
2254
  label="Recurrency",
1792
2255
  choices=RECURRENCE_CHOICES,
1793
2256
  initial=ClientReportSchedule.PERIODICITY_NONE,
2257
+ help_text="Defines how often the report should be generated automatically.",
1794
2258
  )
1795
2259
  disable_emails = forms.BooleanField(
1796
2260
  label="Disable email delivery",
@@ -1909,14 +2373,15 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1909
2373
  "package_link",
1910
2374
  "is_current",
1911
2375
  "pypi_url",
2376
+ "release_on",
1912
2377
  "revision_short",
1913
2378
  "published_status",
1914
2379
  )
1915
2380
  list_display_links = ("version",)
1916
2381
  actions = ["publish_release", "validate_releases"]
1917
2382
  change_actions = ["publish_release_action"]
1918
- changelist_actions = ["refresh_from_pypi"]
1919
- readonly_fields = ("pypi_url", "is_current", "revision")
2383
+ changelist_actions = ["refresh_from_pypi", "prepare_next_release"]
2384
+ readonly_fields = ("pypi_url", "release_on", "is_current", "revision")
1920
2385
  fields = (
1921
2386
  "package",
1922
2387
  "release_manager",
@@ -1924,6 +2389,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1924
2389
  "revision",
1925
2390
  "is_current",
1926
2391
  "pypi_url",
2392
+ "release_on",
1927
2393
  )
1928
2394
 
1929
2395
  @admin.display(description="package", ordering="package")
@@ -1951,32 +2417,94 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1951
2417
  return
1952
2418
  releases = resp.json().get("releases", {})
1953
2419
  created = 0
1954
- for version in releases:
1955
- exists = PackageRelease.all_objects.filter(
2420
+ updated = 0
2421
+ restored = 0
2422
+
2423
+ for version, files in releases.items():
2424
+ release_on = self._release_on_from_files(files)
2425
+ release = PackageRelease.all_objects.filter(
1956
2426
  package=package, version=version
1957
- ).exists()
1958
- if not exists:
1959
- PackageRelease.objects.create(
1960
- package=package,
1961
- release_manager=package.release_manager,
1962
- version=version,
1963
- revision="",
1964
- pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
1965
- )
1966
- created += 1
1967
- if created:
1968
- PackageRelease.dump_fixture()
1969
- self.message_user(
1970
- request,
1971
- f"Created {created} release{'s' if created != 1 else ''} from PyPI",
1972
- messages.SUCCESS,
2427
+ ).first()
2428
+ if release:
2429
+ update_fields = []
2430
+ if release.is_deleted:
2431
+ release.is_deleted = False
2432
+ update_fields.append("is_deleted")
2433
+ restored += 1
2434
+ if not release.pypi_url:
2435
+ release.pypi_url = (
2436
+ f"https://pypi.org/project/{package.name}/{version}/"
2437
+ )
2438
+ update_fields.append("pypi_url")
2439
+ if release_on and release.release_on != release_on:
2440
+ release.release_on = release_on
2441
+ update_fields.append("release_on")
2442
+ updated += 1
2443
+ if update_fields:
2444
+ release.save(update_fields=update_fields)
2445
+ continue
2446
+ PackageRelease.objects.create(
2447
+ package=package,
2448
+ release_manager=package.release_manager,
2449
+ version=version,
2450
+ revision="",
2451
+ pypi_url=f"https://pypi.org/project/{package.name}/{version}/",
2452
+ release_on=release_on,
1973
2453
  )
2454
+ created += 1
2455
+
2456
+ if created or updated or restored:
2457
+ PackageRelease.dump_fixture()
2458
+ message_parts = []
2459
+ if created:
2460
+ message_parts.append(
2461
+ f"Created {created} release{'s' if created != 1 else ''} from PyPI"
2462
+ )
2463
+ if updated:
2464
+ message_parts.append(
2465
+ f"Updated release date for {updated} release"
2466
+ f"{'s' if updated != 1 else ''}"
2467
+ )
2468
+ if restored:
2469
+ message_parts.append(
2470
+ f"Restored {restored} release{'s' if restored != 1 else ''}"
2471
+ )
2472
+ self.message_user(request, "; ".join(message_parts), messages.SUCCESS)
1974
2473
  else:
1975
2474
  self.message_user(request, "No new releases found", messages.INFO)
1976
2475
 
1977
2476
  refresh_from_pypi.label = "Refresh from PyPI"
1978
2477
  refresh_from_pypi.short_description = "Refresh from PyPI"
1979
2478
 
2479
+ @staticmethod
2480
+ def _release_on_from_files(files):
2481
+ if not files:
2482
+ return None
2483
+ candidates = []
2484
+ for item in files:
2485
+ stamp = item.get("upload_time_iso_8601") or item.get("upload_time")
2486
+ if not stamp:
2487
+ continue
2488
+ when = parse_datetime(stamp)
2489
+ if when is None:
2490
+ continue
2491
+ if timezone.is_naive(when):
2492
+ when = timezone.make_aware(when, datetime.timezone.utc)
2493
+ candidates.append(when.astimezone(datetime.timezone.utc))
2494
+ if not candidates:
2495
+ return None
2496
+ return min(candidates)
2497
+
2498
+ def prepare_next_release(self, request, queryset):
2499
+ package = Package.objects.filter(is_active=True).first()
2500
+ if not package:
2501
+ self.message_user(request, "No active package", messages.ERROR)
2502
+ return redirect("admin:core_packagerelease_changelist")
2503
+ return PackageAdmin._prepare(self, request, package)
2504
+
2505
+ prepare_next_release.label = "Prepare next Release"
2506
+ prepare_next_release.short_description = "Prepare next release"
2507
+
1980
2508
  def _publish_release(self, request, release):
1981
2509
  try:
1982
2510
  release.full_clean()