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.
- lino/__init__.py +8 -3
- lino/api/dd.py +11 -35
- lino/api/doctest.py +49 -17
- lino/api/selenium.py +1 -1
- lino/core/actions.py +25 -23
- lino/core/actors.py +52 -23
- lino/core/choicelists.py +10 -8
- lino/core/dbtables.py +1 -1
- lino/core/elems.py +47 -31
- lino/core/fields.py +19 -9
- lino/core/kernel.py +26 -20
- lino/core/model.py +27 -16
- lino/core/renderer.py +2 -2
- lino/core/requests.py +103 -56
- lino/core/site.py +5 -5
- lino/core/store.py +5 -2
- lino/core/utils.py +12 -7
- lino/help_texts.py +7 -8
- lino/mixins/duplicable.py +6 -4
- lino/mixins/sequenced.py +17 -6
- lino/modlib/__init__.py +0 -2
- lino/modlib/changes/models.py +21 -10
- lino/modlib/checkdata/models.py +59 -24
- lino/modlib/comments/fixtures/demo2.py +12 -3
- lino/modlib/comments/models.py +7 -7
- lino/modlib/comments/ui.py +8 -5
- lino/modlib/export_excel/models.py +7 -5
- lino/modlib/extjs/__init__.py +2 -2
- lino/modlib/extjs/views.py +66 -22
- lino/modlib/help/config/makehelp/conf.tpl.py +1 -1
- lino/modlib/jinja/mixins.py +73 -0
- lino/modlib/jinja/models.py +6 -0
- lino/modlib/linod/__init__.py +1 -0
- lino/modlib/linod/choicelists.py +21 -0
- lino/modlib/linod/consumers.py +13 -4
- lino/modlib/linod/fixtures/__init__.py +0 -0
- lino/modlib/linod/fixtures/linod.py +32 -0
- lino/modlib/linod/management/commands/linod.py +6 -2
- lino/modlib/linod/mixins.py +18 -14
- lino/modlib/linod/models.py +4 -2
- lino/modlib/memo/mixins.py +2 -1
- lino/modlib/memo/parser.py +1 -1
- lino/modlib/notify/models.py +19 -11
- lino/modlib/printing/actions.py +47 -42
- lino/modlib/printing/choicelists.py +17 -15
- lino/modlib/printing/mixins.py +22 -20
- lino/modlib/publisher/models.py +5 -5
- lino/modlib/summaries/models.py +3 -2
- lino/modlib/system/models.py +28 -29
- lino/modlib/uploads/__init__.py +14 -11
- lino/modlib/uploads/actions.py +2 -8
- lino/modlib/uploads/choicelists.py +10 -10
- lino/modlib/uploads/fixtures/std.py +17 -0
- lino/modlib/uploads/mixins.py +20 -8
- lino/modlib/uploads/models.py +62 -38
- lino/modlib/uploads/ui.py +15 -9
- lino/utils/__init__.py +0 -1
- lino/utils/jscompressor.py +4 -4
- lino/utils/media.py +45 -23
- lino/utils/report.py +5 -4
- lino/utils/restify.py +2 -2
- lino/utils/soup.py +26 -8
- lino/utils/xml.py +19 -5
- {lino-25.2.2.dist-info → lino-25.3.0.dist-info}/METADATA +1 -1
- {lino-25.2.2.dist-info → lino-25.3.0.dist-info}/RECORD +68 -65
- lino/mixins/uploadable.py +0 -3
- lino/utils/requests.py +0 -55
- {lino-25.2.2.dist-info → lino-25.3.0.dist-info}/WHEEL +0 -0
- {lino-25.2.2.dist-info → lino-25.3.0.dist-info}/licenses/AUTHORS.rst +0 -0
- {lino-25.2.2.dist-info → lino-25.3.0.dist-info}/licenses/COPYING +0 -0
lino/modlib/extjs/views.py
CHANGED
@@ -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(
|
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(
|
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(
|
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
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
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(
|
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(
|
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)
|
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(
|
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(
|
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(
|
608
|
-
|
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
|
lino/modlib/linod/__init__.py
CHANGED
lino/modlib/linod/choicelists.py
CHANGED
@@ -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)
|
lino/modlib/linod/consumers.py
CHANGED
@@ -36,7 +36,10 @@ from .utils import BROADCAST_CHANNEL
|
|
36
36
|
# logger.addHandler(logging.StreamHandler())
|
37
37
|
# logger.setLevel(logging.INFO)
|
38
38
|
|
39
|
-
|
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
|
-
|
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(
|
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"],
|
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-
|
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
|
-
|
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))
|
lino/modlib/linod/mixins.py
CHANGED
@@ -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,
|
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(
|
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(
|
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(
|
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
|
-
|
85
|
-
|
86
|
-
|
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 +
|
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()))
|
lino/modlib/linod/models.py
CHANGED
@@ -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(
|
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)
|
lino/modlib/memo/mixins.py
CHANGED
@@ -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
|
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:
|
lino/modlib/memo/parser.py
CHANGED
lino/modlib/notify/models.py
CHANGED
@@ -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(
|
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,
|
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(
|
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(
|
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 +
|
285
|
+
sender += subaddress_separator + \
|
286
|
+
str(msg.reply_to_id)
|
280
287
|
elif msg.owner_id:
|
281
|
-
sender += subaddress_separator +
|
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,
|
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
|
-
@
|
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
|
-
@
|
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
|
-
# @
|
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
|
-
@
|
499
|
+
@schedule_daily()
|
492
500
|
def clear_seen_messages(ar):
|
493
501
|
Message = rt.models.notify.Message
|
494
502
|
qs = Message.objects.filter(
|