arthexis 0.1.9__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

core/admin.py CHANGED
@@ -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
@@ -61,7 +62,15 @@ from .models import (
61
62
  Todo,
62
63
  hash_key,
63
64
  )
64
- from .user_data import EntityModelAdmin, delete_user_fixture, dump_user_fixture
65
+ from .user_data import (
66
+ EntityModelAdmin,
67
+ UserDatumAdminMixin,
68
+ delete_user_fixture,
69
+ dump_user_fixture,
70
+ _fixture_path,
71
+ _resolve_fixture_user,
72
+ _user_allows_user_data,
73
+ )
65
74
  from .widgets import OdooProductWidget
66
75
  from .mcp import process as mcp_process
67
76
 
@@ -161,11 +170,97 @@ class SaveBeforeChangeAction(DjangoObjectActions):
161
170
  return super().response_change(request, obj)
162
171
 
163
172
 
173
+ class ProfileAdminMixin:
174
+ """Reusable actions for profile-bound admin classes."""
175
+
176
+ def _resolve_my_profile_target(self, request):
177
+ opts = self.model._meta
178
+ changelist_url = reverse(
179
+ f"admin:{opts.app_label}_{opts.model_name}_changelist"
180
+ )
181
+ user = getattr(request, "user", None)
182
+ if not getattr(user, "is_authenticated", False):
183
+ return (
184
+ changelist_url,
185
+ _("You must be logged in to manage your profile."),
186
+ messages.ERROR,
187
+ )
188
+
189
+ profile = self.model._default_manager.filter(user=user).first()
190
+ if profile is not None:
191
+ permission_check = getattr(self, "has_view_or_change_permission", None)
192
+ has_permission = (
193
+ permission_check(request, obj=profile)
194
+ if callable(permission_check)
195
+ else self.has_change_permission(request, obj=profile)
196
+ )
197
+ if has_permission:
198
+ change_url = reverse(
199
+ f"admin:{opts.app_label}_{opts.model_name}_change",
200
+ args=[profile.pk],
201
+ )
202
+ return change_url, None, None
203
+ return (
204
+ changelist_url,
205
+ _("You do not have permission to view this profile."),
206
+ messages.ERROR,
207
+ )
208
+
209
+ if self.has_add_permission(request):
210
+ add_url = reverse(f"admin:{opts.app_label}_{opts.model_name}_add")
211
+ params = {}
212
+ user_id = getattr(user, "pk", None)
213
+ if user_id:
214
+ params["user"] = user_id
215
+ if params:
216
+ add_url = f"{add_url}?{urlencode(params)}"
217
+ return add_url, None, None
218
+
219
+ return (
220
+ changelist_url,
221
+ _("You do not have permission to create this profile."),
222
+ messages.ERROR,
223
+ )
224
+
225
+ def get_my_profile_url(self, request):
226
+ url, _message, _level = self._resolve_my_profile_target(request)
227
+ return url
228
+
229
+ def _redirect_to_my_profile(self, request):
230
+ target_url, message, level = self._resolve_my_profile_target(request)
231
+ if message:
232
+ self.message_user(request, message, level=level)
233
+ return HttpResponseRedirect(target_url)
234
+
235
+ @admin.action(description=_("Active Profile"))
236
+ def my_profile(self, request, queryset=None):
237
+ return self._redirect_to_my_profile(request)
238
+
239
+ def my_profile_action(self, request, obj=None):
240
+ return self._redirect_to_my_profile(request)
241
+
242
+ my_profile_action.label = _("Active Profile")
243
+ my_profile_action.short_description = _("Active Profile")
244
+
245
+ def get_actions(self, request):
246
+ actions = super().get_actions(request)
247
+ if "my_profile" not in actions:
248
+ action = getattr(self, "my_profile", None)
249
+ if action is not None:
250
+ actions["my_profile"] = (
251
+ action,
252
+ "my_profile",
253
+ getattr(action, "short_description", _("Active Profile")),
254
+ )
255
+ return actions
256
+
257
+
164
258
  @admin.register(ExperienceReference)
165
259
  class ReferenceAdmin(EntityModelAdmin):
166
260
  list_display = (
167
261
  "alt_text",
168
262
  "content_type",
263
+ "header",
169
264
  "footer",
170
265
  "visibility",
171
266
  "author",
@@ -182,6 +277,7 @@ class ReferenceAdmin(EntityModelAdmin):
182
277
  "features",
183
278
  "sites",
184
279
  "include_in_footer",
280
+ "show_in_header",
185
281
  "footer_visibility",
186
282
  "transaction_uuid",
187
283
  "author",
@@ -200,6 +296,10 @@ class ReferenceAdmin(EntityModelAdmin):
200
296
  def footer(self, obj):
201
297
  return obj.include_in_footer
202
298
 
299
+ @admin.display(description="Header", boolean=True, ordering="show_in_header")
300
+ def header(self, obj):
301
+ return obj.show_in_header
302
+
203
303
  @admin.display(description="Visibility", ordering="footer_visibility")
204
304
  def visibility(self, obj):
205
305
  return obj.get_footer_visibility_display()
@@ -260,11 +360,12 @@ class ReleaseManagerAdminForm(forms.ModelForm):
260
360
 
261
361
 
262
362
  @admin.register(ReleaseManager)
263
- class ReleaseManagerAdmin(SaveBeforeChangeAction, EntityModelAdmin):
363
+ class ReleaseManagerAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
264
364
  form = ReleaseManagerAdminForm
265
365
  list_display = ("owner", "pypi_username", "pypi_url")
266
366
  actions = ["test_credentials"]
267
- change_actions = ["test_credentials_action"]
367
+ change_actions = ["test_credentials_action", "my_profile_action"]
368
+ changelist_actions = ["my_profile"]
268
369
  fieldsets = (
269
370
  ("Owner", {"fields": ("user", "group")}),
270
371
  (
@@ -325,9 +426,6 @@ class ReleaseManagerAdmin(SaveBeforeChangeAction, EntityModelAdmin):
325
426
  request, f"{manager} credentials check failed: {exc}", messages.ERROR
326
427
  )
327
428
 
328
- def get_model_perms(self, request):
329
- return {}
330
-
331
429
 
332
430
  @admin.register(Package)
333
431
  class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
@@ -338,7 +436,6 @@ class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
338
436
  "release_manager",
339
437
  "is_active",
340
438
  )
341
- actions = ["prepare_next_release"]
342
439
  change_actions = ["prepare_next_release_action"]
343
440
 
344
441
  def _prepare(self, request, package):
@@ -395,13 +492,6 @@ class PackageAdmin(SaveBeforeChangeAction, EntityModelAdmin):
395
492
  return redirect("admin:core_package_changelist")
396
493
  return self._prepare(request, package)
397
494
 
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
495
  def prepare_next_release_action(self, request, obj):
406
496
  return self._prepare(request, obj)
407
497
 
@@ -617,6 +707,17 @@ class EmailInboxAdminForm(forms.ModelForm):
617
707
  class ProfileInlineFormSet(BaseInlineFormSet):
618
708
  """Hide deletion controls and allow implicit removal when empty."""
619
709
 
710
+ @classmethod
711
+ def get_default_prefix(cls):
712
+ prefix = super().get_default_prefix()
713
+ if prefix:
714
+ return prefix
715
+ model_name = cls.model._meta.model_name
716
+ remote_field = getattr(cls.fk, "remote_field", None)
717
+ if remote_field is not None and getattr(remote_field, "one_to_one", False):
718
+ return model_name
719
+ return f"{model_name}_set"
720
+
620
721
  def add_fields(self, form, index):
621
722
  super().add_fields(form, index)
622
723
  if "DELETE" in form.fields:
@@ -974,21 +1075,61 @@ class UserPhoneNumberInline(admin.TabularInline):
974
1075
  fields = ("number", "priority")
975
1076
 
976
1077
 
977
- class UserAdmin(DjangoUserAdmin):
1078
+ class UserAdmin(UserDatumAdminMixin, DjangoUserAdmin):
978
1079
  fieldsets = _append_operate_as(DjangoUserAdmin.fieldsets)
979
1080
  add_fieldsets = _append_operate_as(DjangoUserAdmin.add_fieldsets)
980
1081
  inlines = USER_PROFILE_INLINES + [UserPhoneNumberInline]
981
1082
  change_form_template = "admin/user_profile_change_form.html"
1083
+ _skip_entity_user_datum = True
1084
+
1085
+ def _get_operate_as_profile_template(self):
1086
+ opts = self.model._meta
1087
+ try:
1088
+ return reverse(
1089
+ f"{self.admin_site.name}:{opts.app_label}_{opts.model_name}_change",
1090
+ args=["__ID__"],
1091
+ )
1092
+ except NoReverseMatch:
1093
+ user_opts = User._meta
1094
+ try:
1095
+ return reverse(
1096
+ f"{self.admin_site.name}:{user_opts.app_label}_{user_opts.model_name}_change",
1097
+ args=["__ID__"],
1098
+ )
1099
+ except NoReverseMatch:
1100
+ return None
982
1101
 
983
1102
  def render_change_form(
984
1103
  self, request, context, add=False, change=False, form_url="", obj=None
985
1104
  ):
986
- context = super().render_change_form(
1105
+ response = super().render_change_form(
987
1106
  request, context, add=add, change=change, form_url=form_url, obj=obj
988
1107
  )
989
- context["show_user_datum"] = False
990
- context["show_seed_datum"] = False
991
- return context
1108
+ if isinstance(response, dict):
1109
+ context_data = response
1110
+ else:
1111
+ context_data = getattr(response, "context_data", None)
1112
+ if context_data is not None:
1113
+ context_data["show_user_datum"] = False
1114
+ context_data["show_seed_datum"] = False
1115
+ context_data["show_save_as_copy"] = False
1116
+ operate_as_user = None
1117
+ operate_as_template = self._get_operate_as_profile_template()
1118
+ operate_as_url = None
1119
+ if obj and getattr(obj, "operate_as_id", None):
1120
+ try:
1121
+ operate_as_user = obj.operate_as
1122
+ except User.DoesNotExist:
1123
+ operate_as_user = None
1124
+ if operate_as_user and operate_as_template:
1125
+ operate_as_url = operate_as_template.replace(
1126
+ "__ID__", str(operate_as_user.pk)
1127
+ )
1128
+ if context_data is not None:
1129
+ context_data["operate_as_user"] = operate_as_user
1130
+ context_data["operate_as_profile_url_template"] = operate_as_template
1131
+ context_data["operate_as_profile_url"] = operate_as_url
1132
+ return response
992
1133
 
993
1134
  def get_inline_instances(self, request, obj=None):
994
1135
  inline_instances = super().get_inline_instances(request, obj)
@@ -1035,6 +1176,25 @@ class UserAdmin(DjangoUserAdmin):
1035
1176
  should_store = bool(inline_form.cleaned_data.get("user_datum"))
1036
1177
  self._update_profile_fixture(instance, owner_user, store=should_store)
1037
1178
 
1179
+ def save_model(self, request, obj, form, change):
1180
+ super().save_model(request, obj, form, change)
1181
+ if not getattr(obj, "pk", None):
1182
+ return
1183
+ target_user = _resolve_fixture_user(obj, obj)
1184
+ allow_user_data = _user_allows_user_data(target_user)
1185
+ if request.POST.get("_user_datum") == "on":
1186
+ type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
1187
+ obj.is_user_data = False
1188
+ delete_user_fixture(obj, target_user)
1189
+ self.message_user(
1190
+ request,
1191
+ _("User data for user accounts is managed through the profile sections."),
1192
+ )
1193
+ elif obj.is_user_data:
1194
+ type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
1195
+ obj.is_user_data = False
1196
+ delete_user_fixture(obj, target_user)
1197
+
1038
1198
 
1039
1199
  class EmailCollectorInline(admin.TabularInline):
1040
1200
  model = EmailCollector
@@ -1047,16 +1207,20 @@ class EmailCollectorAdmin(EntityModelAdmin):
1047
1207
 
1048
1208
 
1049
1209
  @admin.register(OdooProfile)
1050
- class OdooProfileAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1210
+ class OdooProfileAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
1051
1211
  change_form_template = "django_object_actions/change_form.html"
1052
1212
  form = OdooProfileAdminForm
1053
1213
  list_display = ("owner", "host", "database", "verified_on")
1054
1214
  readonly_fields = ("verified_on", "odoo_uid", "name", "email")
1055
1215
  actions = ["verify_credentials"]
1056
- change_actions = ["verify_credentials_action"]
1216
+ change_actions = ["verify_credentials_action", "my_profile_action"]
1217
+ changelist_actions = ["my_profile"]
1057
1218
  fieldsets = (
1058
1219
  ("Owner", {"fields": ("user", "group")}),
1059
- (None, {"fields": ("host", "database", "username", "password")}),
1220
+ (
1221
+ "Configuration",
1222
+ {"fields": ("host", "database", "username", "password")},
1223
+ ),
1060
1224
  (
1061
1225
  "Odoo Employee",
1062
1226
  {"fields": ("verified_on", "odoo_uid", "name", "email")},
@@ -1088,9 +1252,6 @@ class OdooProfileAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1088
1252
  verify_credentials_action.label = "Test credentials"
1089
1253
  verify_credentials_action.short_description = "Test credentials"
1090
1254
 
1091
- def get_model_perms(self, request):
1092
- return {}
1093
-
1094
1255
 
1095
1256
  class EmailSearchForm(forms.Form):
1096
1257
  subject = forms.CharField(
@@ -1107,11 +1268,12 @@ class EmailSearchForm(forms.Form):
1107
1268
  )
1108
1269
 
1109
1270
 
1110
- class EmailInboxAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1271
+ class EmailInboxAdmin(ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin):
1111
1272
  form = EmailInboxAdminForm
1112
1273
  list_display = ("owner_label", "username", "host", "protocol")
1113
1274
  actions = ["test_connection", "search_inbox", "test_collectors"]
1114
- change_actions = ["test_collectors_action"]
1275
+ change_actions = ["test_collectors_action", "my_profile_action"]
1276
+ changelist_actions = ["my_profile"]
1115
1277
  change_form_template = "admin/core/emailinbox/change_form.html"
1116
1278
  inlines = [EmailCollectorInline]
1117
1279
 
@@ -1244,12 +1406,16 @@ class EmailInboxAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1244
1406
 
1245
1407
 
1246
1408
  @admin.register(AssistantProfile)
1247
- class AssistantProfileAdmin(EntityModelAdmin):
1409
+ class AssistantProfileAdmin(
1410
+ ProfileAdminMixin, SaveBeforeChangeAction, EntityModelAdmin
1411
+ ):
1248
1412
  list_display = ("owner", "created_at", "last_used_at", "is_active")
1249
1413
  readonly_fields = ("user_key_hash", "created_at", "last_used_at")
1250
1414
 
1251
1415
  change_form_template = "admin/workgroupassistantprofile_change_form.html"
1252
1416
  change_list_template = "admin/assistantprofile_change_list.html"
1417
+ change_actions = ["my_profile_action"]
1418
+ changelist_actions = ["my_profile"]
1253
1419
  fieldsets = (
1254
1420
  ("Owner", {"fields": ("user", "group")}),
1255
1421
  (
@@ -1406,9 +1572,6 @@ class AssistantProfileAdmin(EntityModelAdmin):
1406
1572
  self.message_user(request, msg, level=level)
1407
1573
  return self._redirect_to_changelist()
1408
1574
 
1409
- def get_model_perms(self, request):
1410
- return {}
1411
-
1412
1575
 
1413
1576
  class EnergyCreditInline(admin.TabularInline):
1414
1577
  model = EnergyCredit
@@ -1614,9 +1777,192 @@ class ProductAdminForm(forms.ModelForm):
1614
1777
  widgets = {"odoo_product": OdooProductWidget}
1615
1778
 
1616
1779
 
1780
+ class ProductFetchWizardForm(forms.Form):
1781
+ name = forms.CharField(label="Name", required=False)
1782
+ default_code = forms.CharField(label="Internal reference", required=False)
1783
+ barcode = forms.CharField(label="Barcode", required=False)
1784
+ renewal_period = forms.IntegerField(
1785
+ label="Renewal period (days)", min_value=1, initial=30
1786
+ )
1787
+
1788
+ def __init__(self, *args, require_search_terms=True, **kwargs):
1789
+ self.require_search_terms = require_search_terms
1790
+ super().__init__(*args, **kwargs)
1791
+
1792
+ def clean(self):
1793
+ cleaned = super().clean()
1794
+ if self.require_search_terms:
1795
+ if not any(
1796
+ cleaned.get(field) for field in ("name", "default_code", "barcode")
1797
+ ):
1798
+ raise forms.ValidationError(
1799
+ _("Enter at least one field to search for a product.")
1800
+ )
1801
+ return cleaned
1802
+
1803
+ def build_domain(self):
1804
+ domain = []
1805
+ if self.cleaned_data.get("name"):
1806
+ domain.append(("name", "ilike", self.cleaned_data["name"]))
1807
+ if self.cleaned_data.get("default_code"):
1808
+ domain.append(("default_code", "ilike", self.cleaned_data["default_code"]))
1809
+ if self.cleaned_data.get("barcode"):
1810
+ domain.append(("barcode", "ilike", self.cleaned_data["barcode"]))
1811
+ return domain
1812
+
1813
+
1617
1814
  @admin.register(Product)
1618
1815
  class ProductAdmin(EntityModelAdmin):
1619
1816
  form = ProductAdminForm
1817
+ actions = ["fetch_odoo_product"]
1818
+
1819
+ def _odoo_profile_admin(self):
1820
+ return self.admin_site._registry.get(OdooProfile)
1821
+
1822
+ def _search_odoo_products(self, profile, form):
1823
+ domain = form.build_domain()
1824
+ return profile.execute(
1825
+ "product.product",
1826
+ "search_read",
1827
+ domain,
1828
+ {
1829
+ "fields": [
1830
+ "name",
1831
+ "default_code",
1832
+ "barcode",
1833
+ "description_sale",
1834
+ ],
1835
+ "limit": 50,
1836
+ },
1837
+ )
1838
+
1839
+ @admin.action(description="Fetch Odoo Product")
1840
+ def fetch_odoo_product(self, request, queryset):
1841
+ profile = getattr(request.user, "odoo_profile", None)
1842
+ has_credentials = bool(profile and profile.is_verified)
1843
+ profile_admin = self._odoo_profile_admin()
1844
+ profile_url = None
1845
+ if profile_admin is not None:
1846
+ profile_url = profile_admin.get_my_profile_url(request)
1847
+
1848
+ context = {
1849
+ "opts": self.model._meta,
1850
+ "queryset": queryset,
1851
+ "action": "fetch_odoo_product",
1852
+ "has_credentials": has_credentials,
1853
+ "profile_url": profile_url,
1854
+ }
1855
+
1856
+ if not has_credentials:
1857
+ context["credential_error"] = _(
1858
+ "Configure your Odoo employee credentials before fetching products."
1859
+ )
1860
+ return TemplateResponse(
1861
+ request, "admin/core/product/fetch_odoo.html", context
1862
+ )
1863
+
1864
+ is_import = "import" in request.POST
1865
+ form_kwargs = {"require_search_terms": not is_import}
1866
+ if request.method == "POST":
1867
+ form = ProductFetchWizardForm(request.POST, **form_kwargs)
1868
+ else:
1869
+ form = ProductFetchWizardForm()
1870
+
1871
+ results = None
1872
+ selected_product_id = request.POST.get("product_id", "")
1873
+
1874
+ if request.method == "POST" and form.is_valid():
1875
+ try:
1876
+ results = self._search_odoo_products(profile, form)
1877
+ except Exception:
1878
+ form.add_error(None, _("Unable to fetch products from Odoo."))
1879
+ results = []
1880
+ else:
1881
+ if is_import:
1882
+ if not self.has_add_permission(request):
1883
+ form.add_error(
1884
+ None, _("You do not have permission to add products.")
1885
+ )
1886
+ else:
1887
+ product_id = request.POST.get("product_id")
1888
+ if not product_id:
1889
+ form.add_error(None, _("Select a product to import."))
1890
+ else:
1891
+ try:
1892
+ odoo_id = int(product_id)
1893
+ except (TypeError, ValueError):
1894
+ form.add_error(None, _("Invalid product selection."))
1895
+ else:
1896
+ match = next(
1897
+ (item for item in results if item.get("id") == odoo_id),
1898
+ None,
1899
+ )
1900
+ if not match:
1901
+ form.add_error(
1902
+ None,
1903
+ _(
1904
+ "The selected product was not found. Run the search again."
1905
+ ),
1906
+ )
1907
+ else:
1908
+ existing = self.model.objects.filter(
1909
+ odoo_product__id=odoo_id
1910
+ ).first()
1911
+ if existing:
1912
+ self.message_user(
1913
+ request,
1914
+ _(
1915
+ "Product %(name)s already imported; opening existing record."
1916
+ )
1917
+ % {"name": existing.name},
1918
+ level=messages.WARNING,
1919
+ )
1920
+ return HttpResponseRedirect(
1921
+ reverse(
1922
+ "admin:%s_%s_change"
1923
+ % (
1924
+ existing._meta.app_label,
1925
+ existing._meta.model_name,
1926
+ ),
1927
+ args=[existing.pk],
1928
+ )
1929
+ )
1930
+ product = self.model.objects.create(
1931
+ name=match.get("name") or f"Odoo Product {odoo_id}",
1932
+ description=match.get("description_sale", "") or "",
1933
+ renewal_period=form.cleaned_data["renewal_period"],
1934
+ odoo_product={
1935
+ "id": odoo_id,
1936
+ "name": match.get("name", ""),
1937
+ },
1938
+ )
1939
+ self.log_addition(
1940
+ request, product, "Imported product from Odoo"
1941
+ )
1942
+ self.message_user(
1943
+ request,
1944
+ _("Imported %(name)s from Odoo.")
1945
+ % {"name": product.name},
1946
+ )
1947
+ return HttpResponseRedirect(
1948
+ reverse(
1949
+ "admin:%s_%s_change"
1950
+ % (
1951
+ product._meta.app_label,
1952
+ product._meta.model_name,
1953
+ ),
1954
+ args=[product.pk],
1955
+ )
1956
+ )
1957
+ context.update(
1958
+ {
1959
+ "form": form,
1960
+ "results": results,
1961
+ "selected_product_id": selected_product_id,
1962
+ }
1963
+ )
1964
+ context["media"] = self.media + form.media
1965
+ return TemplateResponse(request, "admin/core/product/fetch_odoo.html", context)
1620
1966
 
1621
1967
 
1622
1968
  class RFIDResource(resources.ModelResource):
@@ -1756,30 +2102,39 @@ class ClientReportAdmin(EntityModelAdmin):
1756
2102
  ]
1757
2103
  RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
1758
2104
  period = forms.ChoiceField(
1759
- choices=PERIOD_CHOICES, widget=forms.RadioSelect, initial="range"
2105
+ choices=PERIOD_CHOICES,
2106
+ widget=forms.RadioSelect,
2107
+ initial="range",
2108
+ help_text="Choose how the reporting window will be calculated.",
1760
2109
  )
1761
2110
  start = forms.DateField(
1762
2111
  label="Start date",
1763
2112
  required=False,
1764
2113
  widget=forms.DateInput(attrs={"type": "date"}),
2114
+ help_text="First day included when using a custom date range.",
1765
2115
  )
1766
2116
  end = forms.DateField(
1767
2117
  label="End date",
1768
2118
  required=False,
1769
2119
  widget=forms.DateInput(attrs={"type": "date"}),
2120
+ help_text="Last day included when using a custom date range.",
1770
2121
  )
1771
2122
  week = forms.CharField(
1772
2123
  label="Week",
1773
2124
  required=False,
1774
2125
  widget=forms.TextInput(attrs={"type": "week"}),
2126
+ help_text="Generates the report for the ISO week that you select.",
1775
2127
  )
1776
2128
  month = forms.DateField(
1777
2129
  label="Month",
1778
2130
  required=False,
1779
2131
  widget=forms.DateInput(attrs={"type": "month"}),
2132
+ help_text="Generates the report for the calendar month that you select.",
1780
2133
  )
1781
2134
  owner = forms.ModelChoiceField(
1782
- queryset=get_user_model().objects.all(), required=False
2135
+ queryset=get_user_model().objects.all(),
2136
+ required=False,
2137
+ help_text="Sets who owns the report schedule and is listed as the requestor.",
1783
2138
  )
1784
2139
  destinations = forms.CharField(
1785
2140
  label="Email destinations",
@@ -1791,6 +2146,7 @@ class ClientReportAdmin(EntityModelAdmin):
1791
2146
  label="Recurrency",
1792
2147
  choices=RECURRENCE_CHOICES,
1793
2148
  initial=ClientReportSchedule.PERIODICITY_NONE,
2149
+ help_text="Defines how often the report should be generated automatically.",
1794
2150
  )
1795
2151
  disable_emails = forms.BooleanField(
1796
2152
  label="Disable email delivery",
@@ -1915,7 +2271,7 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1915
2271
  list_display_links = ("version",)
1916
2272
  actions = ["publish_release", "validate_releases"]
1917
2273
  change_actions = ["publish_release_action"]
1918
- changelist_actions = ["refresh_from_pypi"]
2274
+ changelist_actions = ["refresh_from_pypi", "prepare_next_release"]
1919
2275
  readonly_fields = ("pypi_url", "is_current", "revision")
1920
2276
  fields = (
1921
2277
  "package",
@@ -1977,6 +2333,16 @@ class PackageReleaseAdmin(SaveBeforeChangeAction, EntityModelAdmin):
1977
2333
  refresh_from_pypi.label = "Refresh from PyPI"
1978
2334
  refresh_from_pypi.short_description = "Refresh from PyPI"
1979
2335
 
2336
+ def prepare_next_release(self, request, queryset):
2337
+ package = Package.objects.filter(is_active=True).first()
2338
+ if not package:
2339
+ self.message_user(request, "No active package", messages.ERROR)
2340
+ return redirect("admin:core_packagerelease_changelist")
2341
+ return PackageAdmin._prepare(self, request, package)
2342
+
2343
+ prepare_next_release.label = "Prepare next Release"
2344
+ prepare_next_release.short_description = "Prepare next release"
2345
+
1980
2346
  def _publish_release(self, request, release):
1981
2347
  try:
1982
2348
  release.full_clean()
core/apps.py CHANGED
@@ -105,6 +105,7 @@ class CoreConfig(AppConfig):
105
105
  lock = Path(settings.BASE_DIR) / "locks" / "celery.lck"
106
106
 
107
107
  if lock.exists():
108
+ from .auto_upgrade import ensure_auto_upgrade_periodic_task
108
109
 
109
110
  def ensure_email_collector_task(**kwargs):
110
111
  try: # pragma: no cover - optional dependency
@@ -131,6 +132,8 @@ class CoreConfig(AppConfig):
131
132
  pass
132
133
 
133
134
  post_migrate.connect(ensure_email_collector_task, sender=self)
135
+ post_migrate.connect(ensure_auto_upgrade_periodic_task, sender=self)
136
+ ensure_auto_upgrade_periodic_task()
134
137
 
135
138
  from django.db.backends.signals import connection_created
136
139