lino 25.2.2__py3-none-any.whl → 25.3.0__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.
Files changed (70) hide show
  1. lino/__init__.py +8 -3
  2. lino/api/dd.py +11 -35
  3. lino/api/doctest.py +49 -17
  4. lino/api/selenium.py +1 -1
  5. lino/core/actions.py +25 -23
  6. lino/core/actors.py +52 -23
  7. lino/core/choicelists.py +10 -8
  8. lino/core/dbtables.py +1 -1
  9. lino/core/elems.py +47 -31
  10. lino/core/fields.py +19 -9
  11. lino/core/kernel.py +26 -20
  12. lino/core/model.py +27 -16
  13. lino/core/renderer.py +2 -2
  14. lino/core/requests.py +103 -56
  15. lino/core/site.py +5 -5
  16. lino/core/store.py +5 -2
  17. lino/core/utils.py +12 -7
  18. lino/help_texts.py +7 -8
  19. lino/mixins/duplicable.py +6 -4
  20. lino/mixins/sequenced.py +17 -6
  21. lino/modlib/__init__.py +0 -2
  22. lino/modlib/changes/models.py +21 -10
  23. lino/modlib/checkdata/models.py +59 -24
  24. lino/modlib/comments/fixtures/demo2.py +12 -3
  25. lino/modlib/comments/models.py +7 -7
  26. lino/modlib/comments/ui.py +8 -5
  27. lino/modlib/export_excel/models.py +7 -5
  28. lino/modlib/extjs/__init__.py +2 -2
  29. lino/modlib/extjs/views.py +66 -22
  30. lino/modlib/help/config/makehelp/conf.tpl.py +1 -1
  31. lino/modlib/jinja/mixins.py +73 -0
  32. lino/modlib/jinja/models.py +6 -0
  33. lino/modlib/linod/__init__.py +1 -0
  34. lino/modlib/linod/choicelists.py +21 -0
  35. lino/modlib/linod/consumers.py +13 -4
  36. lino/modlib/linod/fixtures/__init__.py +0 -0
  37. lino/modlib/linod/fixtures/linod.py +32 -0
  38. lino/modlib/linod/management/commands/linod.py +6 -2
  39. lino/modlib/linod/mixins.py +18 -14
  40. lino/modlib/linod/models.py +4 -2
  41. lino/modlib/memo/mixins.py +2 -1
  42. lino/modlib/memo/parser.py +1 -1
  43. lino/modlib/notify/models.py +19 -11
  44. lino/modlib/printing/actions.py +47 -42
  45. lino/modlib/printing/choicelists.py +17 -15
  46. lino/modlib/printing/mixins.py +22 -20
  47. lino/modlib/publisher/models.py +5 -5
  48. lino/modlib/summaries/models.py +3 -2
  49. lino/modlib/system/models.py +28 -29
  50. lino/modlib/uploads/__init__.py +14 -11
  51. lino/modlib/uploads/actions.py +2 -8
  52. lino/modlib/uploads/choicelists.py +10 -10
  53. lino/modlib/uploads/fixtures/std.py +17 -0
  54. lino/modlib/uploads/mixins.py +20 -8
  55. lino/modlib/uploads/models.py +62 -38
  56. lino/modlib/uploads/ui.py +15 -9
  57. lino/utils/__init__.py +0 -1
  58. lino/utils/jscompressor.py +4 -4
  59. lino/utils/media.py +45 -23
  60. lino/utils/report.py +5 -4
  61. lino/utils/restify.py +2 -2
  62. lino/utils/soup.py +26 -8
  63. lino/utils/xml.py +19 -5
  64. {lino-25.2.2.dist-info → lino-25.3.0.dist-info}/METADATA +1 -1
  65. {lino-25.2.2.dist-info → lino-25.3.0.dist-info}/RECORD +68 -65
  66. lino/mixins/uploadable.py +0 -3
  67. lino/utils/requests.py +0 -55
  68. {lino-25.2.2.dist-info → lino-25.3.0.dist-info}/WHEEL +0 -0
  69. {lino-25.2.2.dist-info → lino-25.3.0.dist-info}/licenses/AUTHORS.rst +0 -0
  70. {lino-25.2.2.dist-info → lino-25.3.0.dist-info}/licenses/COPYING +0 -0
@@ -27,6 +27,7 @@ import os
27
27
  from pathlib import Path
28
28
 
29
29
  from django import http
30
+ from django.contrib.messages import success
30
31
  from django.db import models
31
32
  from django.conf import settings
32
33
  from django.core.cache import cache
@@ -38,6 +39,7 @@ from django.utils.decorators import method_decorator
38
39
  from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
39
40
  from django.utils.translation import gettext as _
40
41
  from django.utils.encoding import force_str
42
+ from django.utils.html import mark_safe
41
43
  from lino.core import auth
42
44
 
43
45
  from lino.core.signals import pre_ui_delete
@@ -181,7 +183,8 @@ class SWView(TemplateView):
181
183
  class RunJasmine(View):
182
184
  def get(self, request, *args, **kw):
183
185
  return http.HttpResponse(
184
- settings.SITE.kernel.extjs_renderer.html_page(request, run_jasmine=True)
186
+ settings.SITE.kernel.extjs_renderer.html_page(
187
+ request, run_jasmine=True)
185
188
  )
186
189
 
187
190
 
@@ -203,7 +206,8 @@ class ActionParamChoices(View):
203
206
  if ba is None:
204
207
  raise Exception("Unknown action %r for %s" % (an, actor))
205
208
  field = ba.action.get_param_elem(field)
206
- qs, row2dict = choices_for_field(ba.request(request=request), ba.action, field)
209
+ qs, row2dict = choices_for_field(
210
+ ba.request(request=request), ba.action, field)
207
211
  if field.blank:
208
212
  emptyValue = "<br/>"
209
213
  else:
@@ -313,7 +317,8 @@ class Restful(View):
313
317
  ar.renderer = settings.SITE.kernel.extjs_renderer
314
318
  ar.form2obj_and_save(data, elem, False)
315
319
  # Ext.ensible needs grid_fields, not detail_fields
316
- ar.set_response(rows=[rh.store.row2dict(ar, elem, rh.store.grid_fields)])
320
+ ar.set_response(rows=[rh.store.row2dict(
321
+ ar, elem, rh.store.grid_fields)])
317
322
  return json_response(ar.response)
318
323
 
319
324
 
@@ -343,19 +348,53 @@ class ApiElement(View):
343
348
  ba = rpt.detail_action
344
349
  if ba is None:
345
350
  raise http.Http404("%s has no detail_action" % rpt)
351
+
352
+ fmt = request.GET.get(constants.URL_PARAM_FORMAT,
353
+ ba.action.default_format)
354
+
346
355
  try:
347
356
  if pk and pk != "-99999" and pk != "-99998":
348
357
  sr = [pk]
349
- # if issubclass(rpt.model, models.Model):
350
- # try:
351
- # ar = ba.request(request=request, selected_pks=sr)
352
- # # except ObjectDoesNotExist as e: # 20250212
353
- # except rpt.model.DoesNotExist as e:
354
- # # print("20240911", e)
355
- # raise http.Http404(f"Object {sr} does not exist on {rpt}")
356
- # else:
357
- # ar = ba.request(request=request, selected_pks=sr)
358
- ar = ba.request(request=request, selected_pks=sr)
358
+ if issubclass(rpt.model, models.Model):
359
+ try:
360
+ ar = ba.request(request=request, selected_pks=sr)
361
+ # except ObjectDoesNotExist as e: # 20250212
362
+ except rpt.model.DoesNotExist:
363
+ if fmt == constants.URL_FORMAT_JSON:
364
+ # rescue_ar: without sr and even request, to render a table request (grid view action) on breadcrumb
365
+ rescue_ar = rpt.request(
366
+ renderer=settings.SITE.kernel.default_renderer)
367
+ default_table = rpt.model.get_default_table()
368
+
369
+ title = tostring(rescue_ar.href_to_request(
370
+ rescue_ar, icon_name=None))
371
+
372
+ def get_response():
373
+ msg = mark_safe(
374
+ f'Record (pk={pk}) is no longer available on current table.')
375
+ datarec = dict(
376
+ success=False, message=msg, title=title)
377
+ datarec.update(**vm)
378
+ return datarec
379
+
380
+ try:
381
+ # take default table and try to show the row
382
+ ar = default_table.detail_action.request(
383
+ request=request, selected_pks=sr)
384
+ except default_table.model.DoesNotExist:
385
+ return json_response(get_response())
386
+
387
+ url = ar.obj2url(ar.selected_rows[0])
388
+ datarec = get_response()
389
+ datarec['message'] += mark_safe(
390
+ f' Reload in <a href="{url}">{default_table}</a>.')
391
+ return json_response(datarec)
392
+ # print("20240911", e)
393
+ raise http.Http404(
394
+ f"Object {sr} does not exist on {rpt}")
395
+ else:
396
+ ar = ba.request(request=request, selected_pks=sr)
397
+ # ar = ba.request(request=request, selected_pks=sr)
359
398
  elem = ar.selected_rows[0]
360
399
  # print(
361
400
  # "20170116 views.ApiElement.get", ba,
@@ -398,8 +437,6 @@ class ApiElement(View):
398
437
 
399
438
  # print("20240402 permission", ar, "granted to", ar.get_user(), ar.bound_action.action.select_rows, ar.selected_rows)
400
439
 
401
- fmt = request.GET.get(constants.URL_PARAM_FORMAT, ba.action.default_format)
402
-
403
440
  if ba.action.opens_a_window:
404
441
  if fmt == constants.URL_FORMAT_JSON:
405
442
  if pk == "-99999":
@@ -409,7 +446,8 @@ class ApiElement(View):
409
446
  elem = ar.create_instance()
410
447
  datarec = elem2rec_empty(ar, ar.ah, elem)
411
448
  elif elem is None:
412
- datarec = dict(success=False, message=NOT_FOUND % (rpt, pk))
449
+ datarec = dict(
450
+ success=False, message=NOT_FOUND % (rpt, pk))
413
451
  else:
414
452
  datarec = ar.elem2rec_detailed(elem)
415
453
  datarec.update(**vm)
@@ -500,7 +538,8 @@ class ApiList(View):
500
538
  # Have same-origin policy work for iframe of file upload. see ticket #2885
501
539
  # https://stackoverflow.com/questions/22627392/extjs-fileuplaod-cross-origin-frame
502
540
  response.content = """<html><head><script type="text/javascript">document.domain="{}";</script></head><body>{}</body></html>""".format(
503
- request.POST["_document_domain"], response.content.decode("utf-8")
541
+ request.POST["_document_domain"], response.content.decode(
542
+ "utf-8")
504
543
  )
505
544
  return response
506
545
 
@@ -519,7 +558,8 @@ class ApiList(View):
519
558
  # print(20170921, fmt)
520
559
 
521
560
  if fmt == constants.URL_FORMAT_JSON:
522
- rows = [rh.store.row2list(ar, row) for row in ar.sliced_data_iterator]
561
+ rows = [rh.store.row2list(ar, row)
562
+ for row in ar.sliced_data_iterator]
523
563
  total_count = ar.get_total_count()
524
564
  # raise Exception("20171208 {}".format(ar.data_iterator.query))
525
565
  for row in ar.create_phantom_rows():
@@ -562,7 +602,8 @@ class ApiList(View):
562
602
  sp = request.GET.get(constants.URL_PARAM_SHOW_PARAMS_PANEL, None)
563
603
  if sp is not None:
564
604
  # ~ after_show.update(show_params_panel=sp)
565
- after_show.update(show_params_panel=constants.parse_boolean(sp))
605
+ after_show.update(
606
+ show_params_panel=constants.parse_boolean(sp))
566
607
 
567
608
  # if isinstance(ar.bound_action.action, actions.ShowInsert):
568
609
  # elem = ar.create_instance()
@@ -581,7 +622,8 @@ class ApiList(View):
581
622
  if fmt == "csv":
582
623
  # ~ response = HttpResponse(mimetype='text/csv')
583
624
  charset = settings.SITE.csv_params.get("encoding", "utf-8")
584
- response = http.HttpResponse(content_type='text/csv;charset="%s"' % charset)
625
+ response = http.HttpResponse(
626
+ content_type='text/csv;charset="%s"' % charset)
585
627
  if False:
586
628
  response["Content-Disposition"] = (
587
629
  'attachment; filename="%s.csv"' % ar.actor
@@ -604,8 +646,10 @@ class ApiList(View):
604
646
 
605
647
  if fmt == constants.URL_FORMAT_PRINTER:
606
648
  if ar.get_total_count() > MAX_ROW_COUNT:
607
- raise Exception(_("List contains more than %d rows") % MAX_ROW_COUNT)
608
- response = http.HttpResponse(content_type='text/html;charset="utf-8"')
649
+ raise Exception(
650
+ _("List contains more than %d rows") % MAX_ROW_COUNT)
651
+ response = http.HttpResponse(
652
+ content_type='text/html;charset="utf-8"')
609
653
  doc = Document(force_str(ar.get_title()))
610
654
  doc.body.append(E.h1(doc.title))
611
655
  t = doc.add_table()
@@ -10,7 +10,7 @@ intersphinx_mapping = {}
10
10
 
11
11
  {% if makehelp.language.index == 0 -%}
12
12
 
13
- html_context = dict(public_url="{{settings.SITE.server_url}}media/cache/help")
13
+ html_context = dict(public_url="{{settings.SITE.server_url}}/media/cache/help")
14
14
 
15
15
  from rstgen.sphinxconf import configure ; configure(globals())
16
16
  from lino.sphinxcontrib import configure ; configure(globals())
@@ -0,0 +1,73 @@
1
+ # -*- coding: UTF-8 -*-
2
+ # Copyright 2022-2025 Rumma & Ko Ltd
3
+ # License: GNU Affero General Public License v3 (see file COPYING for details)
4
+
5
+ import os
6
+ import base64
7
+ from pathlib import Path
8
+ from lxml import etree
9
+
10
+ from django.conf import settings
11
+ from django.utils import translation
12
+ from django.utils.html import mark_safe, escape
13
+
14
+ from lino.api import dd
15
+ from lino.utils.xml import validate_xml
16
+ from lino.utils.media import MediaFile
17
+
18
+
19
+ def xml_element(name, value):
20
+ if value:
21
+ return f"<{name}>{escape(str(value))}</{name}>"
22
+ return ""
23
+
24
+
25
+ class XMLMaker(dd.Model):
26
+
27
+ class Meta:
28
+ abstract = True
29
+
30
+ xml_validator_file = None
31
+ xml_file_template = None
32
+ # xml_file_name = None
33
+
34
+ def get_xml_file_parts(self):
35
+ yield 'xml'
36
+ yield self.get_printable_target_stem() + ".xml"
37
+
38
+ def make_xml_file(self, ar):
39
+ renderer = settings.SITE.plugins.jinja.renderer
40
+ tpl = renderer.jinja_env.get_template(self.xml_file_template)
41
+ context = self.get_printable_context(ar)
42
+ context.update(xml_element=xml_element)
43
+ context.update(base64=base64)
44
+ xml = tpl.render(**context)
45
+ # parts = [
46
+ # dd.plugins.accounting.xml_media_dir,
47
+ # self.xml_file_name.format(self=self)]
48
+ xmlfile = MediaFile(False, *self.get_xml_file_parts())
49
+ # xmlfile = Path(settings.MEDIA_ROOT, *parts)
50
+ ar.logger.info("Make %s from %s ...", xmlfile.path, self)
51
+ xmlfile.path.parent.mkdir(exist_ok=True, parents=True)
52
+ xmlfile.path.write_text(xml)
53
+ # xmlfile.write_text(etree.tostring(xml))
54
+
55
+ if self.xml_validator_file:
56
+ # print("20250218 {xml[:100]}")
57
+ # doc = etree.fromstring(xml.encode("utf-8"))
58
+ ar.logger.info("Validate %s against %s ...",
59
+ xmlfile.path.name, self.xml_validator_file)
60
+ if True:
61
+ validate_xml(xmlfile.path, self.xml_validator_file)
62
+ else:
63
+ try:
64
+ validate_xml(xmlfile.path, self.xml_validator_file)
65
+ except Exception as e:
66
+ msg = _("XML validation failed: {}").format(e)
67
+ # print(msg)
68
+ raise Warning(msg)
69
+
70
+ # url = settings.SITE.build_media_url(*parts)
71
+ # return mark_safe(f"""<a href="{url}">{url}</a>""")
72
+ # return (xmlfile, url)
73
+ return xmlfile
@@ -0,0 +1,6 @@
1
+ # -*- coding: UTF-8 -*-
2
+ # Copyright 2025 Rumma & Ko Ltd
3
+ # License: GNU Affero General Public License v3 (see file COPYING for details)
4
+
5
+ from .mixins import XMLMaker
6
+ from .choicelists import JinjaBuildMethod
@@ -20,6 +20,7 @@ class Plugin(ad.Plugin):
20
20
  verbose_name = _("Lino daemon")
21
21
  use_channels = False
22
22
  background_sleep_time = 5 # in seconds
23
+ daemon_user = "robin" # TODO: find a better solution
23
24
 
24
25
  def on_plugins_loaded(self, site):
25
26
  assert self.site is site
@@ -80,3 +80,24 @@ add("INFO")
80
80
  add("WARNING")
81
81
  add("ERROR")
82
82
  add("CRITICAL")
83
+
84
+
85
+ def background_task(**kwargs):
86
+ if "class_name" not in kwargs:
87
+ kwargs["class_name"] = "linod.SystemTask"
88
+
89
+ def decorator(func):
90
+ Procedures.add_item(func, **kwargs)
91
+ return func
92
+
93
+ return decorator
94
+
95
+
96
+ def schedule_often(every=10, **kwargs):
97
+ kwargs.update(every_unit="secondly", every=every)
98
+ return background_task(**kwargs)
99
+
100
+
101
+ def schedule_daily(**kwargs):
102
+ kwargs.update(every_unit="daily", every=1)
103
+ return background_task(**kwargs)
@@ -36,7 +36,10 @@ from .utils import BROADCAST_CHANNEL
36
36
  # logger.addHandler(logging.StreamHandler())
37
37
  # logger.setLevel(logging.INFO)
38
38
 
39
- is_slave = lambda actor: actor.master is not None
39
+
40
+ def is_slave(actor): return actor.master is not None
41
+
42
+
40
43
  match_master = (
41
44
  lambda ContentType, MasterModel, Master: Master == MasterModel
42
45
  or ContentType == Master
@@ -57,8 +60,12 @@ class LinodConsumer(AsyncConsumer):
57
60
  async def run_background_tasks(self, event: dict):
58
61
  # 'run.background.tasks' in `pm linod`
59
62
  from lino.modlib.linod.mixins import start_task_runner
63
+ # from lino.core.utils import login
64
+ # ar = login(settings.SITE.plugins.linod.daemon_user)
60
65
  from lino.core.requests import BaseRequest
61
- ar = BaseRequest()
66
+ u = await settings.SITE.user_model.objects.aget(
67
+ username=settings.SITE.plugins.linod.daemon_user)
68
+ ar = BaseRequest(user=u)
62
69
  asyncio.ensure_future(start_task_runner(ar))
63
70
 
64
71
  async def send_push(self, event):
@@ -82,7 +89,8 @@ class LinodConsumer(AsyncConsumer):
82
89
  if user is None:
83
90
  subs = settings.SITE.models.notify.Subscription.objects.all()
84
91
  else:
85
- subs = settings.SITE.models.notify.Subscription.objects.filter(user=user)
92
+ subs = settings.SITE.models.notify.Subscription.objects.filter(
93
+ user=user)
86
94
  async for sub in subs.aiterator():
87
95
  sub_info = {
88
96
  "endpoint": sub.endpoint,
@@ -122,7 +130,8 @@ class LinodConsumer(AsyncConsumer):
122
130
  data.update(
123
131
  **dict(
124
132
  [
125
- (a.actor_id, {"mk": msg["mk"], "mt": mt, "pk": msg["pk"]})
133
+ (a.actor_id, {"mk": msg["mk"],
134
+ "mt": mt, "pk": msg["pk"]})
126
135
  for a in ups
127
136
  if is_slave(a)
128
137
  and match_master(ContentType, MasterModel, a.master)
File without changes
@@ -0,0 +1,32 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright 2025 Rumma & Ko Ltd
3
+ # License: GNU Affero General Public License v3 (see file COPYING for details)
4
+ from asgiref.sync import async_to_sync
5
+ from lino.api import rt
6
+ from lino.modlib.linod.mixins import start_task_runner
7
+
8
+ # import logging
9
+ # from lino import logger
10
+ # def getHandlerByName(name):
11
+ # for l in logger.handlers:
12
+ # if l.name == name:
13
+ # return l
14
+
15
+ def objects():
16
+ raise Exception("""
17
+
18
+ This fixture isn't used at the moment. I wrote it because I thought it
19
+ would be nice to run the system task runner automatically when ``pm prep``
20
+ in order to cover the sync_ibanity system task. But (1) this would require
21
+ me to integrate also the ``checkdata`` and ``checksummaries`` fixtures into
22
+ it (otherwise they would run again as a system task) and (2) we don't want
23
+ to start `sync_ibanity` automatically on GitLab because it can't work
24
+ without credentials.
25
+
26
+ """)
27
+ ar = rt.login("robin")
28
+ # logger.setLevel(logging.DEBUG)
29
+ # getHandlerByName('console').setLevel(logging.DEBUG)
30
+ # ar.debug("Coucou")
31
+ async_to_sync(start_task_runner)(ar, max_count=1)
32
+ return []
@@ -1,5 +1,5 @@
1
1
  # -*- coding: UTF-8 -*-
2
- # Copyright 2022-2024 Rumma & Ko Ltd
2
+ # Copyright 2022-2025 Rumma & Ko Ltd
3
3
  # License: GNU Affero General Public License v3 (see file COPYING for details)
4
4
 
5
5
  # import time
@@ -10,6 +10,7 @@ from django.conf import settings
10
10
  from django.core.management import BaseCommand, call_command
11
11
  from lino.api import dd, rt
12
12
  from lino.modlib.linod.mixins import start_log_server, start_task_runner
13
+ from lino.core.requests import BaseRequest
13
14
 
14
15
  if dd.plugins.linod.use_channels:
15
16
  import threading
@@ -50,7 +51,10 @@ class Command(BaseCommand):
50
51
  # print("20240424 Run Lino daemon without channels")
51
52
 
52
53
  async def main():
53
- ar = rt.login()
54
+ u = await settings.SITE.user_model.objects.aget(
55
+ username=settings.SITE.plugins.linod.daemon_user)
56
+ ar = BaseRequest(user=u)
57
+ # ar = rt.login(dd.plugins.linod.daemon_user)
54
58
  await asyncio.gather(start_log_server(), start_task_runner(ar))
55
59
  # t1 = asyncio.create_task(settings.SITE.start_log_server())
56
60
  # t2 = asyncio.create_task(start_task_runner(ar))
@@ -4,20 +4,18 @@
4
4
  # See https://dev.lino-framework.org/plugins/linod.html
5
5
 
6
6
  import logging
7
- import sys
8
7
  import traceback
9
8
  import asyncio
10
9
  import pickle
11
10
  from datetime import timedelta
12
- from io import StringIO
13
11
  from django.conf import settings
14
12
  from django.db import models
15
13
  from django.utils import timezone
16
- from django.core.exceptions import ValidationError
14
+ # from django.core.exceptions import ValidationError
17
15
  from asgiref.sync import sync_to_async, async_to_sync
18
16
 
19
17
  from lino import logger
20
- from lino.api import dd, rt, _
18
+ from lino.api import dd, _
21
19
  from lino.mixins import Sequenced
22
20
  from lino.modlib.system.mixins import RecurrenceSet
23
21
  from lino.modlib.system.choicelists import Recurrences
@@ -34,8 +32,9 @@ def astr(self):
34
32
 
35
33
  class RunNow(dd.Action):
36
34
  label = _("Run now")
35
+ help_text = _("Mark the task as to be executed asap by linod.")
37
36
  select_rows = True
38
-
37
+ button_text = "▶"
39
38
  # icon_name = 'bell'
40
39
  # icon_name = 'lightning'
41
40
 
@@ -44,7 +43,6 @@ class RunNow(dd.Action):
44
43
  for obj in ar.selected_rows:
45
44
  assert issubclass(obj.__class__, Runnable)
46
45
  if True: # dd.plugins.linod.use_channels:
47
- # Mark the task as to be executed asap by linod.
48
46
  obj.last_start_time = None
49
47
  obj.last_end_time = None
50
48
  obj.message = "{} requested to run this task at {}.".format(
@@ -65,9 +63,11 @@ class Runnable(Sequenced, RecurrenceSet):
65
63
 
66
64
  log_level = LogLevels.field(default="INFO")
67
65
  disabled = dd.BooleanField(_("Disabled"), default=False)
68
- last_start_time = dd.DateTimeField(_("Started at"), null=True, editable=False)
66
+ last_start_time = dd.DateTimeField(
67
+ _("Started at"), null=True, editable=False)
69
68
  last_end_time = dd.DateTimeField(_("Ended at"), null=True, editable=False)
70
- message = dd.RichTextField(_("Logged messages"), format="plain", editable=False)
69
+ message = dd.RichTextField(
70
+ _("Logged messages"), format="plain", editable=False)
71
71
 
72
72
  # procedure = Procedures.field(strict=False, unique=True, editable=False)
73
73
  procedure = Procedures.field(strict=False)
@@ -76,14 +76,17 @@ class Runnable(Sequenced, RecurrenceSet):
76
76
  run_now = RunNow()
77
77
 
78
78
  def __str__(self):
79
- r = "{} #{} ({})".format(self._meta.verbose_name, self.seqno, self.name)
79
+ r = "{} #{} ({})".format(
80
+ self._meta.verbose_name, self.seqno, self.name)
80
81
  return r
81
82
 
82
83
  def full_clean(self, *args, **kwargs):
83
84
  super().full_clean(*args, **kwargs)
84
- class_name = dd.full_model_name(self.__class__)
85
- if self.procedure.class_name != class_name:
86
- raise ValidationError(f"Invalid procedure for {class_name}")
85
+ # 20250213 The following caused 'Invalid procedure invoicing.Task for
86
+ # linod.SystemTask' during restore.py:
87
+ # class_name = dd.full_model_name(self.__class__)
88
+ # if self.procedure.class_name != class_name:
89
+ # raise ValidationError(f"Invalid procedure {self.procedure.class_name} for {class_name}")
87
90
  if self.every_unit is None:
88
91
  self.every_unit = Recurrences.never
89
92
  if not self.name:
@@ -157,7 +160,7 @@ class Runnable(Sequenced, RecurrenceSet):
157
160
  return _("Scheduled to run at {}").format(dd.ftl(next_time))
158
161
 
159
162
 
160
- async def start_task_runner(ar, max_count=None):
163
+ async def start_task_runner(ar=None, max_count=None):
161
164
  # called from consumers.LinoConsumer.run_background_tasks()
162
165
  await ar.ainfo("Start task runner using %s...", ar.logger)
163
166
  # ar.info("Start task runner using %s...", ar.logger)
@@ -168,7 +171,8 @@ async def start_task_runner(ar, max_count=None):
168
171
  await ar.adebug("Start next task runner loop.")
169
172
 
170
173
  now = timezone.now()
171
- next_time = now + timedelta(seconds=dd.plugins.linod.background_sleep_time)
174
+ next_time = now + \
175
+ timedelta(seconds=dd.plugins.linod.background_sleep_time)
172
176
 
173
177
  for cls in Procedures.task_classes():
174
178
  # asyncio.ensure_future(m.start_task_runner(ar.spawn_request()))
@@ -30,11 +30,12 @@ class SystemTask(Runnable):
30
30
  class SystemTasks(dd.Table):
31
31
  # label = _("System tasks")
32
32
  model = "linod.SystemTask"
33
+ order_by = ['seqno']
33
34
  required_roles = dd.login_required(SiteStaff)
34
35
  column_names = "seqno name log_level disabled status procedure *"
35
36
  detail_layout = """
36
37
  seqno procedure
37
- name
38
+ name
38
39
  every every_unit
39
40
  log_level disabled status
40
41
  last_start_time last_end_time
@@ -58,7 +59,8 @@ class SystemTaskChecker(Checker):
58
59
  for proc in Procedures.get_list_items():
59
60
  if proc.class_name == "linod.SystemTask":
60
61
  if SystemTask.objects.filter(procedure=proc).count() == 0:
61
- msg = _("No {} for {}").format(SystemTask._meta.verbose_name, proc)
62
+ msg = _("No {} for {}").format(
63
+ SystemTask._meta.verbose_name, proc)
62
64
  yield (True, msg)
63
65
  if fix:
64
66
  logger.debug("Create background task for %r", proc)
@@ -43,7 +43,8 @@ def rich_text_to_elems(ar, description):
43
43
 
44
44
  # After 20250213 #5929 (Links in the description of a ticket aren't rendered
45
45
  # correctly) we no longer try to automatically detect reSTructuredText
46
- # markup in a RichTextField (anyway nobody has ever used this feature).
46
+ # markup in a RichTextField. Anyway nobody has ever used this feature
47
+ # (except for the furniture fixture of the products plugin).
47
48
 
48
49
  # if description.startswith("<"):
49
50
  if True:
@@ -135,7 +135,7 @@ class Parser:
135
135
  def compile_suggester_regex(self):
136
136
  triggers = "".join(
137
137
  [
138
- "\\" if key in "[\^$.|?*+(){}" else "" + key
138
+ r"\\" if key in r"[\^$.|?*+(){}" else "" + key
139
139
  for key in self.suggesters.keys()
140
140
  ]
141
141
  )
@@ -22,6 +22,7 @@ from lino.utils.format_date import fds
22
22
  from lino.mixins import Created, ObservedDateRange
23
23
  from lino.modlib.gfks.mixins import Controllable
24
24
  from lino.modlib.users.mixins import UserAuthored, My
25
+ from lino.modlib.linod.choicelists import schedule_daily, schedule_often
25
26
  from lino.modlib.office.roles import OfficeUser
26
27
 
27
28
  from .choicelists import MessageTypes, MailModes
@@ -29,7 +30,9 @@ from .api import send_notification, NOTIFICATION, send_panel_update
29
30
 
30
31
  html_parser = etree.HTMLParser()
31
32
 
32
- subaddress_separator = dd.get_plugin_setting('inbox', 'subaddress_separator', None)
33
+ subaddress_separator = dd.get_plugin_setting(
34
+ 'inbox', 'subaddress_separator', None)
35
+
33
36
 
34
37
  def groupname(s):
35
38
  # Remove any invalid characters from the given string so that it can
@@ -126,7 +129,8 @@ class Message(UserAuthored, Controllable, Created):
126
129
  message_type = MessageTypes.field(default="change")
127
130
  seen = models.DateTimeField(_("seen"), null=True, editable=False)
128
131
  sent = models.DateTimeField(_("sent"), null=True, editable=False)
129
- body = dd.RichTextField(_("Body"), editable=False, format="html", default="")
132
+ body = dd.RichTextField(_("Body"), editable=False,
133
+ format="html", default="")
130
134
  mail_mode = MailModes.field(default="often")
131
135
  subject = models.CharField(_("Subject"), max_length=250, editable=False)
132
136
  reply_to = dd.ForeignKey(
@@ -171,7 +175,8 @@ class Message(UserAuthored, Controllable, Created):
171
175
  ba = owner.get_detail_action(ar)
172
176
  if ba is not None:
173
177
  push_options.update(
174
- action_url=ar.renderer.get_detail_url(ar, ba.actor, owner.pk)
178
+ action_url=ar.renderer.get_detail_url(
179
+ ar, ba.actor, owner.pk)
175
180
  )
176
181
  # push_options.update(action_url=ar.renderer.obj2url(ar, owner))
177
182
  me = ar.get_user()
@@ -267,7 +272,8 @@ class Message(UserAuthored, Controllable, Created):
267
272
  collect(u, obj)
268
273
  else:
269
274
  collect(obj.user, obj)
270
- ar.logger.debug("Send out '%s' summaries for %d users.", mm, len(users))
275
+ ar.logger.debug(
276
+ "Send out '%s' summaries for %d users.", mm, len(users))
271
277
  for user, messages in users.items():
272
278
  with translation.override(user.language):
273
279
  sender = sender_parts[0]
@@ -276,9 +282,11 @@ class Message(UserAuthored, Controllable, Created):
276
282
  subject = msg.subject
277
283
  if subaddress_separator is not None:
278
284
  if msg.reply_to_id:
279
- sender += subaddress_separator + str(msg.reply_to_id)
285
+ sender += subaddress_separator + \
286
+ str(msg.reply_to_id)
280
287
  elif msg.owner_id:
281
- sender += subaddress_separator + str(msg.owner_type)
288
+ sender += subaddress_separator + \
289
+ str(msg.owner_type)
282
290
  sender += subaddress_separator + str(msg.owner_id)
283
291
  else:
284
292
  subject = _("{} notifications").format(len(messages))
@@ -386,7 +394,7 @@ class MyMessages(My, Messages):
386
394
  default_display_modes = {None: constants.DISPLAY_MODE_SUMMARY}
387
395
 
388
396
  @classmethod
389
- def unused_get_table_summary(cls, mi, ar):
397
+ def unused_get_table_summary(cls, ar):
390
398
  # 20240710 Replaced by table_as_summary(), which is now more simple. But
391
399
  # I leave the old version here in case some unexpected regression
392
400
  # occurs.
@@ -460,18 +468,18 @@ class MyMessages(My, Messages):
460
468
  # h)
461
469
 
462
470
 
463
- @dd.schedule_often(every=10)
471
+ @schedule_often(every=10)
464
472
  def send_pending_emails_often(ar):
465
473
  # print("20231021 send_pending_emails_often()", ar.logger)
466
474
  rt.models.notify.Message.send_summary_emails(ar, MailModes.often)
467
475
 
468
476
 
469
- @dd.schedule_daily()
477
+ @schedule_daily()
470
478
  def send_pending_emails_daily(ar):
471
479
  rt.models.notify.Message.send_summary_emails(ar, MailModes.daily)
472
480
 
473
481
 
474
- # @dd.schedule_often(every=10)
482
+ # @schedule_often(every=10)
475
483
  # def send_pending_emails_often(ar):
476
484
  # Message = rt.models.notify.Message
477
485
  # qs = Message.objects.filter(sent__isnull=True)
@@ -488,7 +496,7 @@ remove_after = dd.plugins.notify.remove_after
488
496
 
489
497
  if remove_after:
490
498
 
491
- @dd.schedule_daily()
499
+ @schedule_daily()
492
500
  def clear_seen_messages(ar):
493
501
  Message = rt.models.notify.Message
494
502
  qs = Message.objects.filter(