lino 25.7.2__py3-none-any.whl → 25.8.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 +1 -1
- lino/api/doctest.py +28 -2
- lino/core/actions.py +12 -1
- lino/core/atomizer.py +9 -8
- lino/core/auth/utils.py +9 -1
- lino/core/callbacks.py +2 -2
- lino/core/dbtables.py +4 -1
- lino/core/fields.py +0 -1
- lino/core/kernel.py +9 -19
- lino/core/model.py +11 -3
- lino/core/site.py +10 -14
- lino/core/store.py +3 -3
- lino/core/utils.py +14 -13
- lino/help_texts.py +9 -7
- lino/management/commands/ddt.py +60 -0
- lino/management/commands/dump2py.py +13 -25
- lino/management/commands/prep.py +1 -1
- lino/mixins/__init__.py +3 -2
- lino/mixins/{duplicable.py → clonable.py} +45 -50
- lino/mixins/dupable.py +2 -2
- lino/mixins/registrable.py +3 -3
- lino/mixins/sequenced.py +12 -14
- lino/modlib/comments/mixins.py +5 -6
- lino/modlib/comments/models.py +14 -12
- lino/modlib/comments/ui.py +0 -1
- lino/modlib/dupable/models.py +2 -2
- lino/modlib/memo/mixins.py +2 -0
- lino/modlib/notify/api.py +5 -0
- lino/modlib/printing/mixins.py +2 -2
- lino/modlib/publisher/models.py +15 -8
- lino/modlib/summaries/mixins.py +6 -4
- lino/modlib/users/actions.py +5 -0
- lino/sphinxcontrib/__init__.py +1 -1
- lino/sphinxcontrib/actordoc.py +1 -1
- lino/utils/cycler.py +6 -3
- lino/utils/diag.py +2 -2
- lino/utils/dpy.py +70 -59
- lino/utils/instantiator.py +21 -1
- {lino-25.7.2.dist-info → lino-25.8.0.dist-info}/METADATA +1 -1
- {lino-25.7.2.dist-info → lino-25.8.0.dist-info}/RECORD +43 -42
- {lino-25.7.2.dist-info → lino-25.8.0.dist-info}/WHEEL +0 -0
- {lino-25.7.2.dist-info → lino-25.8.0.dist-info}/licenses/AUTHORS.rst +0 -0
- {lino-25.7.2.dist-info → lino-25.8.0.dist-info}/licenses/COPYING +0 -0
lino/__init__.py
CHANGED
@@ -31,7 +31,7 @@ from django import VERSION
|
|
31
31
|
from django.apps import AppConfig
|
32
32
|
from django.conf import settings
|
33
33
|
import warnings
|
34
|
-
__version__ = '25.
|
34
|
+
__version__ = '25.8.0'
|
35
35
|
|
36
36
|
# import setuptools # avoid UserWarning "Distutils was imported before Setuptools"?
|
37
37
|
|
lino/api/doctest.py
CHANGED
@@ -14,6 +14,7 @@ tested document. It includes
|
|
14
14
|
|
15
15
|
"""
|
16
16
|
|
17
|
+
from lino.mixins.clonable import Clonable
|
17
18
|
from lino.utils.fieldutils import get_fields, fields_help
|
18
19
|
from lino.core.boundaction import BoundAction
|
19
20
|
from lino.core.tables import AbstractTable
|
@@ -23,7 +24,9 @@ from lino.core.actions import ShowTable
|
|
23
24
|
from lino.core.menus import Menu
|
24
25
|
from lino.utils.html import html2text
|
25
26
|
from lino.utils import dbhash
|
26
|
-
from lino.core.utils import
|
27
|
+
from lino.core.utils import get_models
|
28
|
+
from lino.core.utils import full_model_name
|
29
|
+
from lino.core.utils import full_model_name as fmn
|
27
30
|
from lino.utils.diag import visible_for
|
28
31
|
from lino.sphinxcontrib.actordoc import menuselection_text
|
29
32
|
from lino import logger
|
@@ -746,7 +749,7 @@ def show_change_watchers():
|
|
746
749
|
ws = m.change_watcher_spec
|
747
750
|
if ws:
|
748
751
|
rows.append(
|
749
|
-
[
|
752
|
+
[fmn(m), ws.master_key, " ".join(sorted(ws.ignored_fields))]
|
750
753
|
)
|
751
754
|
print(rstgen.table(headers, rows, max_width=40))
|
752
755
|
|
@@ -801,3 +804,26 @@ def checkdb(m, num):
|
|
801
804
|
if m.objects.count() != num:
|
802
805
|
raise Exception(
|
803
806
|
f"Model {m} should have {num} rows but has {m.objects.count()}")
|
807
|
+
|
808
|
+
|
809
|
+
def show_clonables():
|
810
|
+
"""
|
811
|
+
Print a list of all :class:`Clonable <lino.mixins.clonable.Clonable>`
|
812
|
+
models, together with their related slaves, i.e. the data that will be
|
813
|
+
cloned in cascade with their master.
|
814
|
+
"""
|
815
|
+
items = []
|
816
|
+
for m in get_models():
|
817
|
+
if issubclass(m, Clonable):
|
818
|
+
rels = []
|
819
|
+
if (obj := m.objects.first()) is not None:
|
820
|
+
new, related = obj.duplication_plan()
|
821
|
+
for fk, qs in related:
|
822
|
+
rels.append(f"{fmn(qs.model)}.{fk.name}")
|
823
|
+
if len(rels):
|
824
|
+
x = ", ".join(rels)
|
825
|
+
items.append(f"{fmn(m)} : {x}")
|
826
|
+
else:
|
827
|
+
items.append(fmn(m))
|
828
|
+
items = sorted(items)
|
829
|
+
print(rstgen.ul(items).strip())
|
lino/core/actions.py
CHANGED
@@ -98,6 +98,13 @@ class Action(Parametrizable, Permittable):
|
|
98
98
|
raise Exception(
|
99
99
|
"Unkonwn icon_name '{0}'".format(self.icon_name))
|
100
100
|
|
101
|
+
params = {}
|
102
|
+
if self.parameters is not None:
|
103
|
+
params.update(self.parameters)
|
104
|
+
self.setup_parameters(params)
|
105
|
+
if len(params):
|
106
|
+
self.parameters = params
|
107
|
+
|
101
108
|
register_params(self)
|
102
109
|
|
103
110
|
if self.callable_from is not None:
|
@@ -151,6 +158,9 @@ class Action(Parametrizable, Permittable):
|
|
151
158
|
|
152
159
|
return decorator
|
153
160
|
|
161
|
+
def setup_parameters(self, params):
|
162
|
+
pass
|
163
|
+
|
154
164
|
def get_help_text(self, ba):
|
155
165
|
if ba is ba.actor.default_action:
|
156
166
|
if self.default_record_id is not None:
|
@@ -223,7 +233,7 @@ class Action(Parametrizable, Permittable):
|
|
223
233
|
|
224
234
|
def full_name(self, actor=None):
|
225
235
|
if self.action_name is None:
|
226
|
-
raise Exception("Tried to full_name() on
|
236
|
+
raise Exception(f"Tried to full_name() on {repr(self)}")
|
227
237
|
# ~ return repr(self)
|
228
238
|
if actor is None or (self.parameters and not self.no_params_window):
|
229
239
|
return self.defining_actor.actor_id + "." + self.action_name
|
@@ -352,6 +362,7 @@ class ShowDetail(Action):
|
|
352
362
|
ui5_icon_name = "sap-icon://detail-view"
|
353
363
|
opens_a_window = True
|
354
364
|
window_type = constants.WINDOW_TYPE_DETAIL
|
365
|
+
show_in_toolbar = False
|
355
366
|
show_in_workflow = False
|
356
367
|
save_action_name = "submit_detail"
|
357
368
|
callable_from = "t"
|
lino/core/atomizer.py
CHANGED
@@ -309,10 +309,10 @@ def fields_list(model, field_names):
|
|
309
309
|
Return a set with the names of the specified fields, checking
|
310
310
|
whether each of them exists.
|
311
311
|
|
312
|
-
Arguments: `model` is any subclass of `django.db.models.Model`. It
|
313
|
-
|
314
|
-
|
315
|
-
|
312
|
+
Arguments: `model` is any subclass of `django.db.models.Model`. It may be a
|
313
|
+
string with the full name of a model (e.g. ``"myapp.MyModel"``).
|
314
|
+
`field_names` is an iterable of field names or a single string with a
|
315
|
+
space-separated list of field names.
|
316
316
|
|
317
317
|
If one of the names refers to a dummy field, this name will be ignored
|
318
318
|
silently.
|
@@ -326,16 +326,17 @@ def fields_list(model, field_names):
|
|
326
326
|
iterable on the fields.
|
327
327
|
"""
|
328
328
|
lst = set()
|
329
|
-
|
329
|
+
if isinstance(field_names, str):
|
330
|
+
field_names = field_names.split()
|
330
331
|
|
331
|
-
for name in
|
332
|
+
for name in field_names:
|
332
333
|
if name == "*":
|
333
334
|
explicit_names = set()
|
334
335
|
for name in names_list:
|
335
336
|
if name != "*":
|
336
337
|
explicit_names.add(name)
|
337
338
|
for de in fields.wildcard_data_elems(model):
|
338
|
-
if not isinstance(de, DummyField):
|
339
|
+
if not isinstance(de, fields.DummyField):
|
339
340
|
if de.name not in explicit_names:
|
340
341
|
if fields.use_as_wildcard(de):
|
341
342
|
lst.add(de.name)
|
@@ -343,7 +344,7 @@ def fields_list(model, field_names):
|
|
343
344
|
e = model.get_data_elem(name)
|
344
345
|
if e is None:
|
345
346
|
raise fields.FieldDoesNotExist(
|
346
|
-
"No data element
|
347
|
+
f"No data element '{name}' in {model}")
|
347
348
|
if not hasattr(e, "name"):
|
348
349
|
raise fields.FieldDoesNotExist(
|
349
350
|
"%s %r in %s has no name" % (e.__class__, name, model)
|
lino/core/auth/utils.py
CHANGED
@@ -73,7 +73,8 @@ class AnonymousUser:
|
|
73
73
|
|
74
74
|
|
75
75
|
def activate_social_auth_testing(
|
76
|
-
globals_dict, google=True, github=True, wikimedia=True, facebook=True
|
76
|
+
globals_dict, google=True, github=True, wikimedia=True, facebook=True,
|
77
|
+
smart_id=False
|
77
78
|
):
|
78
79
|
"""
|
79
80
|
Used for testing a development server.
|
@@ -97,6 +98,13 @@ def activate_social_auth_testing(
|
|
97
98
|
# 'social_core.backends.google.GoogleOAuth2',
|
98
99
|
# 'social_core.backends.google.GoogleOAuth',
|
99
100
|
# 'social_core.backends.facebook.FacebookOAuth2',
|
101
|
+
if smart_id:
|
102
|
+
Site.social_auth_backends.append("lino.utils.smart_id.SmartID")
|
103
|
+
# https://oauth.ee/docs
|
104
|
+
globals_dict.update(
|
105
|
+
SOCIAL_AUTH_SMART_ID_KEY="xxx",
|
106
|
+
SOCIAL_AUTH_SMART_ID_SECRET="yyy",
|
107
|
+
)
|
100
108
|
if wikimedia:
|
101
109
|
Site.social_auth_backends.append("social_core.backends.mediawiki.MediaWiki")
|
102
110
|
globals_dict.update(
|
lino/core/callbacks.py
CHANGED
@@ -72,9 +72,9 @@ class Callback(object):
|
|
72
72
|
- func: a callable to be executed when user selects this choice
|
73
73
|
- the label of the button
|
74
74
|
"""
|
75
|
-
assert not
|
75
|
+
assert name not in self.choices_dict
|
76
76
|
allowed_names = ("yes", "no", "ok", "cancel")
|
77
|
-
if not
|
77
|
+
if name not in allowed_names:
|
78
78
|
raise Exception("Sorry, name must be one of %s" % allowed_names)
|
79
79
|
cbc = CallbackChoice(name, func, label)
|
80
80
|
self.choices.append(cbc)
|
lino/core/dbtables.py
CHANGED
@@ -440,7 +440,10 @@ class Table(AbstractTable):
|
|
440
440
|
|
441
441
|
# if self.master is not None:
|
442
442
|
# self.master = resolve_model(self.master)
|
443
|
-
|
443
|
+
|
444
|
+
# For abstract tables we don't check the master key, e.g.
|
445
|
+
# tickets.TicketsByGroup
|
446
|
+
if self.master_key and not self.abstract:
|
444
447
|
master_model = None
|
445
448
|
try:
|
446
449
|
fk = self.model.get_data_elem(self.master_key)
|
lino/core/fields.py
CHANGED
@@ -1492,7 +1492,6 @@ class TableRow(object):
|
|
1492
1492
|
|
1493
1493
|
def save_existing_instance(self, ar):
|
1494
1494
|
watcher = ChangeWatcher(self)
|
1495
|
-
# print("20210213 save_existing_instance", ar.ah, ar.rqdata, self.disabled_fields)
|
1496
1495
|
ar.ah.store.form2obj(ar, ar.rqdata, self, False)
|
1497
1496
|
self.full_clean()
|
1498
1497
|
pre_ui_save.send(sender=self.__class__, instance=self, ar=ar)
|
lino/core/kernel.py
CHANGED
@@ -230,25 +230,15 @@ class Kernel(object):
|
|
230
230
|
|
231
231
|
Model.django2lino(model)
|
232
232
|
|
233
|
-
if
|
234
|
-
model.
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
model
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
if isinstance(model.allow_cascaded_delete, str):
|
244
|
-
model.allow_cascaded_delete = frozenset(
|
245
|
-
atomizer.fields_list(model, model.allow_cascaded_delete)
|
246
|
-
)
|
247
|
-
|
248
|
-
if isinstance(model.allow_cascaded_copy, str):
|
249
|
-
model.allow_cascaded_copy = frozenset(
|
250
|
-
atomizer.fields_list(model, model.allow_cascaded_copy)
|
251
|
-
)
|
233
|
+
if model.allow_cascaded_copy is None:
|
234
|
+
model.allow_cascaded_copy = model.allow_cascaded_delete
|
235
|
+
|
236
|
+
# 'suppress_cascaded_copy'
|
237
|
+
for k in ('hidden_columns', 'active_fields',
|
238
|
+
'allow_cascaded_delete', 'allow_cascaded_copy'):
|
239
|
+
# resolve_fields_list(model, k, frozenset, frozenset())
|
240
|
+
if (v := getattr(model, k, None)) is not None:
|
241
|
+
setattr(model, k, atomizer.fields_list(model, v))
|
252
242
|
|
253
243
|
# Note how to inherit this from from parent model.
|
254
244
|
if model.quick_search_fields is None:
|
lino/core/model.py
CHANGED
@@ -47,8 +47,9 @@ class Model(models.Model, fields.TableRow):
|
|
47
47
|
class Meta(object):
|
48
48
|
abstract = True
|
49
49
|
|
50
|
-
allow_cascaded_copy = frozenset()
|
51
50
|
allow_cascaded_delete = frozenset()
|
51
|
+
allow_cascaded_copy = None
|
52
|
+
# suppress_cascaded_copy = frozenset()
|
52
53
|
grid_post = actions.CreateRow()
|
53
54
|
submit_insert = actions.SubmitInsert()
|
54
55
|
allow_merge_action = False
|
@@ -479,14 +480,20 @@ class Model(models.Model, fields.TableRow):
|
|
479
480
|
elem.after_ui_save(ar, None)
|
480
481
|
|
481
482
|
def save_watched_instance(elem, ar, watcher):
|
483
|
+
# raise Exception("20250726")
|
482
484
|
if watcher.is_dirty():
|
483
485
|
# pre_ui_save.send(sender=elem.__class__, instance=elem, ar=ar)
|
484
486
|
# elem.before_ui_save(ar, watcher)
|
485
487
|
elem.save(force_update=True)
|
486
488
|
watcher.send_update(ar)
|
487
489
|
ar.success(_("%s has been updated.") % obj2unicode(elem))
|
488
|
-
else:
|
489
|
-
|
490
|
+
# else:
|
491
|
+
# ar.success(_("%s : nothing to save.") % obj2unicode(elem))
|
492
|
+
# 20250726 The "nothing to save" message is confusing e.g. in
|
493
|
+
# ratings.ResponsesByExam when setting score1 or score2. These virtual
|
494
|
+
# fields store the value in their respective ChallengeRating object but
|
495
|
+
# the examResponse remains unchanged.
|
496
|
+
|
490
497
|
elem.after_ui_save(ar, watcher)
|
491
498
|
|
492
499
|
def delete_instance(self, ar):
|
@@ -948,6 +955,7 @@ LINO_MODEL_ATTRIBS = (
|
|
948
955
|
"before_ui_save",
|
949
956
|
"allow_cascaded_delete",
|
950
957
|
"allow_cascaded_copy",
|
958
|
+
# "suppress_cascaded_copy",
|
951
959
|
"workflow_state_field",
|
952
960
|
"workflow_owner_field",
|
953
961
|
"disabled_fields",
|
lino/core/site.py
CHANGED
@@ -670,18 +670,16 @@ class Site(object):
|
|
670
670
|
"formatter": "verbose",
|
671
671
|
}
|
672
672
|
elif self.use_systemd:
|
673
|
-
|
674
|
-
|
675
|
-
"
|
676
|
-
|
677
|
-
|
678
|
-
|
679
|
-
|
680
|
-
|
681
|
-
|
682
|
-
|
683
|
-
# except ImportError:
|
684
|
-
# pass
|
673
|
+
try:
|
674
|
+
from systemd.journal import JournalHandler
|
675
|
+
handlers["file"] = {
|
676
|
+
"class": "systemd.journal.JournalHandler",
|
677
|
+
"SYSLOG_IDENTIFIER": str(self.project_name),
|
678
|
+
}
|
679
|
+
except ImportError:
|
680
|
+
# Silenly ignore. Can happen when use_systemd is True but
|
681
|
+
# `pm install` hasn't yet been run.
|
682
|
+
pass
|
685
683
|
|
686
684
|
# when a file handler exists, we have the loggers use it even if this
|
687
685
|
# instance didn't create it:
|
@@ -919,8 +917,6 @@ class Site(object):
|
|
919
917
|
extends_models = pc.__dict__.get("extends_models")
|
920
918
|
if extends_models is not None:
|
921
919
|
for m in extends_models:
|
922
|
-
if "." in m:
|
923
|
-
raise Exception("extends_models in %s still uses '.'" % pc)
|
924
920
|
for pp in plugin_parents(pc):
|
925
921
|
if pp is pc:
|
926
922
|
continue
|
lino/core/store.py
CHANGED
@@ -203,7 +203,6 @@ class StoreField(object):
|
|
203
203
|
|
204
204
|
def set_value_in_object(self, ar, instance, v):
|
205
205
|
# logger.info("20180712 super set_value_in_object() %s", v)
|
206
|
-
|
207
206
|
old_value = self.value_from_object(instance, ar.request)
|
208
207
|
# old_value = getattr(instance,self.field.attname)
|
209
208
|
if old_value != v:
|
@@ -865,8 +864,8 @@ class FileFieldStoreField(StoreField):
|
|
865
864
|
|
866
865
|
|
867
866
|
class MethodStoreField(StoreField):
|
868
|
-
"""
|
869
|
-
Still used for DISPLAY_MODE_HTML.
|
867
|
+
"""
|
868
|
+
Still used for DISPLAY_MODE_HTML and writable virtual fields.
|
870
869
|
"""
|
871
870
|
|
872
871
|
def full_value_from_object(self, obj, ar=None):
|
@@ -898,6 +897,7 @@ class MethodStoreField(StoreField):
|
|
898
897
|
# pass
|
899
898
|
|
900
899
|
def form2obj(self, request, instance, post_data, is_new):
|
900
|
+
# print("20250726", self, post_data)
|
901
901
|
pass
|
902
902
|
# return instance
|
903
903
|
# raise Exception("Cannot update a virtual field")
|
lino/core/utils.py
CHANGED
@@ -810,29 +810,30 @@ def error2str(self, e):
|
|
810
810
|
|
811
811
|
|
812
812
|
def resolve_fields_list(model, k, collection_type=tuple, default=None):
|
813
|
-
|
814
|
-
if
|
813
|
+
value = getattr(model, k)
|
814
|
+
if value is None:
|
815
815
|
setattr(model, k, default)
|
816
|
-
|
817
|
-
|
818
|
-
|
819
|
-
|
820
|
-
|
816
|
+
return
|
817
|
+
elif value == default:
|
818
|
+
return
|
819
|
+
elif isinstance(value, collection_type):
|
820
|
+
return
|
821
|
+
if isinstance(value, str):
|
822
|
+
value = value.split()
|
823
|
+
if isinstance(value, (list, tuple)):
|
821
824
|
lst = []
|
822
|
-
for n in
|
825
|
+
for n in value:
|
823
826
|
f = model.get_data_elem(n)
|
824
827
|
if f is None:
|
825
|
-
msg = "Invalid field {} in {} of {}"
|
826
|
-
msg = msg.format(n, k, model)
|
828
|
+
msg = f"Invalid field {n} in {k} of {model}"
|
827
829
|
raise Exception(msg)
|
828
830
|
lst.append(f)
|
829
831
|
setattr(model, k, collection_type(lst))
|
830
832
|
# fields.fields_list(model, model.quick_search_fields))
|
831
833
|
else:
|
832
834
|
raise ChangedAPI(
|
833
|
-
"{
|
834
|
-
"of space-separated field names (not {
|
835
|
-
)
|
835
|
+
f"{model}.{k} must be None or a string "
|
836
|
+
f"of space-separated field names (not {value})")
|
836
837
|
|
837
838
|
|
838
839
|
def class_dict_items(cl, exclude=None):
|
lino/help_texts.py
CHANGED
@@ -17,6 +17,8 @@ help_texts = {
|
|
17
17
|
'lino.mixins.ProjectRelated' : _("""Mixin for models that are related to a “project”, i.e. to an object of the type given by your lino.core.site.Site.project_model."""),
|
18
18
|
'lino.mixins.ProjectRelated.project' : _("""Pointer to the project to which this object is related."""),
|
19
19
|
'lino.mixins.ProjectRelated.update_owned_instance' : _("""When a project-related object controls another project-related object, then the controlled automatically inherits the project of its controller."""),
|
20
|
+
'lino.mixins.clonable.CloneRow' : _("""See /dev/duplicate."""),
|
21
|
+
'lino.mixins.clonable.Clonable' : _("""See /dev/duplicate."""),
|
20
22
|
'lino.mixins.dupable.CheckedSubmitInsert' : _("""Like the standard lino.core.actions.SubmitInsert, but adds a confirmation if there is a possible duplicate record."""),
|
21
23
|
'lino.mixins.dupable.PhoneticWordBase' : _("""Base class for the table of phonetic words of a given dupable model. For every (non-abstract) dupable model there must be a subclass of PhoneticWordBase. The subclass must define a field owner which points to the Dupable, and the Dupable’s dupable_word_model must point to its subclass of PhoneticWordBase."""),
|
22
24
|
'lino.mixins.dupable.Dupable' : _("""Base class for models that can be “dupable”."""),
|
@@ -30,9 +32,6 @@ help_texts = {
|
|
30
32
|
'lino.mixins.dupable.DupableChecker' : _("""Checks for the following repairable problem:"""),
|
31
33
|
'lino.mixins.dupable.DupableChecker.model' : _("""alias of Dupable"""),
|
32
34
|
'lino.mixins.dupable.SimilarObjects' : _("""Shows the other objects that are similar to this one."""),
|
33
|
-
'lino.mixins.duplicable.Duplicate' : _("""Duplicate the selected row."""),
|
34
|
-
'lino.mixins.duplicable.Duplicate.run_from_ui' : _("""This actually runs the action."""),
|
35
|
-
'lino.mixins.duplicable.Duplicable' : _("""Adds a row action “Duplicate”, which duplicates (creates a clone of) the object it was called on."""),
|
36
35
|
'lino.mixins.human.Human' : _("""Base class for models that represent a human."""),
|
37
36
|
'lino.mixins.human.Human.title' : _("""Used to specify a professional position or academic qualification like “Dr.” or “PhD”."""),
|
38
37
|
'lino.mixins.human.Human.first_name' : _("""The first name, also known as given name."""),
|
@@ -87,10 +86,9 @@ help_texts = {
|
|
87
86
|
'lino.mixins.sequenced.MoveByN' : _("""Move this row N rows upwards or downwards."""),
|
88
87
|
'lino.mixins.sequenced.MoveUp' : _("""Move this row one row upwards."""),
|
89
88
|
'lino.mixins.sequenced.MoveDown' : _("""Move this row one row downwards."""),
|
90
|
-
'lino.mixins.sequenced.DuplicateSequenced' : _("""Duplicate this row."""),
|
91
89
|
'lino.mixins.sequenced.Sequenced' : _("""Mixin for models that have a field seqno containing a “sequence number”."""),
|
92
90
|
'lino.mixins.sequenced.Sequenced.seqno' : _("""The sequence number of this item with its parent."""),
|
93
|
-
'lino.mixins.sequenced.Sequenced.
|
91
|
+
'lino.mixins.sequenced.Sequenced.clone_row' : _("""Create a duplicate of this row and insert the new row below this one."""),
|
94
92
|
'lino.mixins.sequenced.Sequenced.move_up' : _("""Exchange the seqno of this item and the previous item."""),
|
95
93
|
'lino.mixins.sequenced.Sequenced.move_down' : _("""Exchange the seqno of this item and the next item."""),
|
96
94
|
'lino.mixins.sequenced.Sequenced.move_buttons' : _("""Displays buttons for certain actions on this row:"""),
|
@@ -234,7 +232,7 @@ help_texts = {
|
|
234
232
|
'lino.utils.djangotest.RestoreTestCase' : _("""Used for testing migrations from previous versions."""),
|
235
233
|
'lino.utils.djangotest.RestoreTestCase.tested_versions' : _("""A list of strings, each string is a version for which there must be a migration dump created by makemigdump."""),
|
236
234
|
'lino.utils.dpy.FakeDeserializedObject' : _("""Imitates DeserializedObject required by loaddata."""),
|
237
|
-
'lino.utils.dpy.FakeDeserializedObject.try_save' : _("""Try to save
|
235
|
+
'lino.utils.dpy.FakeDeserializedObject.try_save' : _("""Try to save this Model instance."""),
|
238
236
|
'lino.utils.dpy.Serializer' : _("""Serializes a QuerySet to a py stream."""),
|
239
237
|
'lino.utils.dpy.FlushDeferredObjects' : _("""Indicator class object. Fixture may yield a FlushDeferredObjects to indicate that all deferred objects should get saved before going on."""),
|
240
238
|
'lino.utils.dpy.DpyLoader' : _("""Instantiated by restore.py."""),
|
@@ -311,7 +309,6 @@ help_texts = {
|
|
311
309
|
'lino.core.model.Model.submit_insert' : _("""The SubmitInsert action to be executed when the when the users submits an insert window."""),
|
312
310
|
'lino.core.model.Model.create_from_choice' : _("""Called when a learning combo has been submitted. Create a persistent database object if the given text contains enough information."""),
|
313
311
|
'lino.core.model.Model.choice_text_to_dict' : _("""Return a dict of the fields to fill when the given text contains enough information for creating a new database object."""),
|
314
|
-
'lino.core.model.Model.allow_cascaded_delete' : _("""A set of names of ForeignKey or GenericForeignKey fields of this model that allow for cascaded delete."""),
|
315
312
|
'lino.core.model.Model.__str__' : _("""Return a translatable text that describes this database row."""),
|
316
313
|
'lino.core.model.Model.as_str' : _("""Return a translatable text that describes this database row. Unlike __str__() this method gets an action request when it is called, so it knows the context."""),
|
317
314
|
'lino.core.model.Model.get_str_words' : _("""Yield a series of words that describe this database row in plain text."""),
|
@@ -717,4 +714,9 @@ help_texts = {
|
|
717
714
|
'lino.core.model.Model.get_choices_text' : _("""Return the text to be displayed when an instance of this model is being used as a choice in a combobox of a ForeignKey field pointing to this model. request is the web request, actor is the requesting actor."""),
|
718
715
|
'lino.core.model.Model.disable_delete' : _("""Decide whether this database object may be deleted. Return None when there is no veto against deleting this database row, otherwise a translatable message that explains to the user why they can’t delete this row."""),
|
719
716
|
'lino.core.model.Model.update_field' : _("""Shortcut to call lino.core.inject.update_field() for usage during lino.core.site.Site.do_site_startup() in a settings.py or similar place."""),
|
717
|
+
'lino.core.model.Model.on_duplicate' : _("""Called after duplicating a row."""),
|
718
|
+
'lino.core.model.Model.after_duplicate' : _("""Called by lino.mixins.clonable.Duplicate on the new copied row instance, after the row and its related fields have been saved."""),
|
719
|
+
'lino.core.model.Model.delete_veto_message' : _("""Return the message Cannot delete X because N Ys refer to it."""),
|
720
|
+
'lino.core.model.Model.allow_cascaded_delete' : _("""A set of foreign key fields that link this model to a master in a “possessive” way, i.e. objects of this model should get deleted together with their master."""),
|
721
|
+
'lino.core.model.Model.allow_cascaded_copy' : _("""A set of foreign key fields that that cause database rows to be automatically cloned when their master gets cloned."""),
|
720
722
|
}
|
@@ -0,0 +1,60 @@
|
|
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
|
+
# import shutil
|
6
|
+
from click import confirm
|
7
|
+
import subprocess
|
8
|
+
from django.db import DEFAULT_DB_ALIAS
|
9
|
+
from django.core.management import call_command
|
10
|
+
from django.core.management.base import BaseCommand, CommandError
|
11
|
+
from django.conf import settings
|
12
|
+
|
13
|
+
|
14
|
+
class Command(BaseCommand):
|
15
|
+
help = "Run a double-dump test on this site."
|
16
|
+
|
17
|
+
def add_arguments(self, parser):
|
18
|
+
super().add_arguments(parser)
|
19
|
+
(
|
20
|
+
parser.add_argument(
|
21
|
+
"-b", "--batch", "--noinput",
|
22
|
+
action="store_false",
|
23
|
+
dest="interactive",
|
24
|
+
default=True,
|
25
|
+
help="Do not prompt for input of any kind.",
|
26
|
+
),
|
27
|
+
)
|
28
|
+
|
29
|
+
def runcmd(self, cmd, **options):
|
30
|
+
bashopts = dict(shell=True, universal_newlines=True)
|
31
|
+
cp = subprocess.run(cmd, **bashopts)
|
32
|
+
if cp.returncode != 0:
|
33
|
+
# subprocess.run("sudo journalctl -xe", **kw)
|
34
|
+
raise CommandError(f"{cmd} ended with return code {cp.returncode}")
|
35
|
+
|
36
|
+
def handle(self, *args, **options):
|
37
|
+
if len(args) > 0:
|
38
|
+
raise CommandError("This command takes no arguments (got %r)" % args)
|
39
|
+
|
40
|
+
using = options.get("database", DEFAULT_DB_ALIAS)
|
41
|
+
dbname = settings.DATABASES[using]["NAME"]
|
42
|
+
interactive = options.get("interactive")
|
43
|
+
if interactive:
|
44
|
+
msg = f"WARNING: running this test can break your database ({dbname})."
|
45
|
+
msg += "\nAre you sure?"
|
46
|
+
if not confirm(msg, default=True):
|
47
|
+
raise CommandError("User abort.")
|
48
|
+
|
49
|
+
kwargs = dict()
|
50
|
+
kwargs["interactive"] = False
|
51
|
+
kwargs["verbosity"] = options.get("verbosity")
|
52
|
+
tmpdir = settings.SITE.site_dir / "tmp"
|
53
|
+
# call_command("prep", '--keepmedia', **kwargs)
|
54
|
+
call_command("dump2py", '-o', tmpdir / "a", **kwargs)
|
55
|
+
self.runcmd(f"python manage.py run {str(tmpdir / 'a' / 'restore.py')} -b")
|
56
|
+
call_command("dump2py", '-o', tmpdir / "b", **kwargs)
|
57
|
+
self.runcmd(f"diff {tmpdir / 'a'} {tmpdir / 'b'}")
|
58
|
+
print(f"Successfully ran double-dump test in {tmpdir}.")
|
59
|
+
# print(f"The double-dump test was successful, we can remove {tmpdir}.")
|
60
|
+
# shutil.rmtree(tmpdir)
|
@@ -300,7 +300,7 @@ def bv2kw(fieldname, values):
|
|
300
300
|
"""
|
301
301
|
|
302
302
|
def main(args):
|
303
|
-
loader = DpyLoader(globals(), quick=args.quick)
|
303
|
+
loader = DpyLoader(globals(), quick=args.quick, strict=True)
|
304
304
|
from django.core.management import call_command
|
305
305
|
call_command('initdb', interactive=args.interactive)
|
306
306
|
os.chdir(os.path.dirname(__file__))
|
@@ -449,35 +449,23 @@ if __name__ == '__main__':
|
|
449
449
|
hope = True
|
450
450
|
break
|
451
451
|
|
452
|
-
|
453
|
-
# ~ for d in deps:
|
454
|
-
# ~ if d in unsorted:
|
455
|
-
# ~ ok = False
|
456
|
-
# ~ if ok:
|
457
|
-
# ~ sorted.append(model)
|
458
|
-
# ~ unsorted.remove(model)
|
459
|
-
# ~ hope = True
|
460
|
-
# ~ break
|
461
|
-
# ~ else:
|
462
|
-
# ~ guilty[model] = deps
|
463
|
-
# ~ print model.__name__, "depends on", [m.__name__ for m in deps]
|
464
|
-
if unsorted:
|
452
|
+
if len(unsorted):
|
465
453
|
assert len(unsorted) == len(guilty)
|
466
|
-
|
454
|
+
todo = [(m, m.objects.count(), guilty[m]) for m in unsorted]
|
455
|
+
todo.sort(key=lambda i: i[1])
|
456
|
+
|
457
|
+
msg = "There are %d models with circular dependencies :\n" % len(todo)
|
467
458
|
msg += "- " + "\n- ".join(
|
468
459
|
[
|
469
460
|
full_model_name(m)
|
470
|
-
+ " (depends on %s)" %
|
471
|
-
|
472
|
-
|
473
|
-
)
|
474
|
-
|
475
|
-
#
|
476
|
-
# in random order which would cause false ddt to fail
|
477
|
-
for ln in msg.splitlines():
|
478
|
-
self.stream.write("\n# %s" % ln)
|
461
|
+
+ " (%d rows, depends on %s)" % (
|
462
|
+
count, ", ".join([full_model_name(d) for d in deps])
|
463
|
+
) for m, count, deps in todo]
|
464
|
+
) + "\n\n"
|
465
|
+
for ln in msg.splitlines():
|
466
|
+
self.stream.write("\n# %s" % ln)
|
479
467
|
logger.info(msg)
|
480
|
-
sorted.extend(
|
468
|
+
sorted.extend([i[0] for i in todo])
|
481
469
|
return sorted
|
482
470
|
|
483
471
|
def value2string(self, obj, field):
|
lino/management/commands/prep.py
CHANGED
lino/mixins/__init__.py
CHANGED
@@ -8,8 +8,9 @@ by applications and the :ref:`xl`. But none of them is mandatory.
|
|
8
8
|
.. autosummary::
|
9
9
|
:toctree:
|
10
10
|
|
11
|
-
|
11
|
+
|
12
12
|
dupable
|
13
|
+
clonable
|
13
14
|
sequenced
|
14
15
|
human
|
15
16
|
periods
|
@@ -50,7 +51,7 @@ from .polymorphic import Polymorphic
|
|
50
51
|
from .periods import ObservedDateRange, Yearly, Monthly, Today
|
51
52
|
from .periods import DateRange
|
52
53
|
from .sequenced import Sequenced, Hierarchical
|
53
|
-
from .
|
54
|
+
from .clonable import Clonable, CloneRow
|
54
55
|
from .registrable import Registrable, RegistrableState
|
55
56
|
from .ref import Referrable, StructuredReferrable
|
56
57
|
|