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.
Files changed (43) hide show
  1. lino/__init__.py +1 -1
  2. lino/api/doctest.py +28 -2
  3. lino/core/actions.py +12 -1
  4. lino/core/atomizer.py +9 -8
  5. lino/core/auth/utils.py +9 -1
  6. lino/core/callbacks.py +2 -2
  7. lino/core/dbtables.py +4 -1
  8. lino/core/fields.py +0 -1
  9. lino/core/kernel.py +9 -19
  10. lino/core/model.py +11 -3
  11. lino/core/site.py +10 -14
  12. lino/core/store.py +3 -3
  13. lino/core/utils.py +14 -13
  14. lino/help_texts.py +9 -7
  15. lino/management/commands/ddt.py +60 -0
  16. lino/management/commands/dump2py.py +13 -25
  17. lino/management/commands/prep.py +1 -1
  18. lino/mixins/__init__.py +3 -2
  19. lino/mixins/{duplicable.py → clonable.py} +45 -50
  20. lino/mixins/dupable.py +2 -2
  21. lino/mixins/registrable.py +3 -3
  22. lino/mixins/sequenced.py +12 -14
  23. lino/modlib/comments/mixins.py +5 -6
  24. lino/modlib/comments/models.py +14 -12
  25. lino/modlib/comments/ui.py +0 -1
  26. lino/modlib/dupable/models.py +2 -2
  27. lino/modlib/memo/mixins.py +2 -0
  28. lino/modlib/notify/api.py +5 -0
  29. lino/modlib/printing/mixins.py +2 -2
  30. lino/modlib/publisher/models.py +15 -8
  31. lino/modlib/summaries/mixins.py +6 -4
  32. lino/modlib/users/actions.py +5 -0
  33. lino/sphinxcontrib/__init__.py +1 -1
  34. lino/sphinxcontrib/actordoc.py +1 -1
  35. lino/utils/cycler.py +6 -3
  36. lino/utils/diag.py +2 -2
  37. lino/utils/dpy.py +70 -59
  38. lino/utils/instantiator.py +21 -1
  39. {lino-25.7.2.dist-info → lino-25.8.0.dist-info}/METADATA +1 -1
  40. {lino-25.7.2.dist-info → lino-25.8.0.dist-info}/RECORD +43 -42
  41. {lino-25.7.2.dist-info → lino-25.8.0.dist-info}/WHEEL +0 -0
  42. {lino-25.7.2.dist-info → lino-25.8.0.dist-info}/licenses/AUTHORS.rst +0 -0
  43. {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.7.2'
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 full_model_name, get_models
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
- [full_model_name(m), ws.master_key, " ".join(sorted(ws.ignored_fields))]
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 %r" % self)
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
- may be a string with the full name of a model
314
- (e.g. ``"myapp.MyModel"``). `field_names` is a single string with
315
- a space-separated list of field names.
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
- names_list = field_names.split()
329
+ if isinstance(field_names, str):
330
+ field_names = field_names.split()
330
331
 
331
- for name in names_list:
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 %r in %s" % (name, model))
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 name in self.choices_dict
75
+ assert name not in self.choices_dict
76
76
  allowed_names = ("yes", "no", "ok", "cancel")
77
- if not name in allowed_names:
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
- if self.master_key:
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 isinstance(model.hidden_columns, str):
234
- model.hidden_columns = frozenset(
235
- atomizer.fields_list(model, model.hidden_columns)
236
- )
237
-
238
- if isinstance(model.active_fields, str):
239
- model.active_fields = frozenset(
240
- atomizer.fields_list(model, model.active_fields)
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
- ar.success(_("%s : nothing to save.") % obj2unicode(elem))
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
- handlers["file"] = {
674
- "class": "systemd.journal.JournalHandler",
675
- "SYSLOG_IDENTIFIER": str(self.project_name),
676
- }
677
- # try:
678
- # from systemd.journal import JournalHandler
679
- # handlers["file"] = {
680
- # "class": "systemd.journal.JournalHandler",
681
- # "SYSLOG_IDENTIFIER": str(self.project_name),
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
- """Deprecated. See `/blog/2012/0327`.
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
- qsf = getattr(model, k)
814
- if qsf is None:
813
+ value = getattr(model, k)
814
+ if value is None:
815
815
  setattr(model, k, default)
816
- elif qsf == default:
817
- pass
818
- elif isinstance(qsf, collection_type):
819
- pass
820
- elif isinstance(qsf, str):
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 qsf.split():
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
- "{0}.{1} must be None or a string "
834
- "of space-separated field names (not {2})".format(model, k, qsf)
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.duplicate' : _("""Create a duplicate of this object and insert the new object below this one."""),
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 the specified Model instance obj. Return True on success, False if this instance wasn’t saved and should be deferred."""),
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
- # ~ ok = True
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
- msg = "There are %d models with circular dependencies :\n" % len(unsorted)
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)" % ", ".join([full_model_name(d) for d in deps])
471
- for m, deps in guilty.items()
472
- ]
473
- )
474
- if False:
475
- # we don't write them to the .py file because they are
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(unsorted)
468
+ sorted.extend([i[0] for i in todo])
481
469
  return sorted
482
470
 
483
471
  def value2string(self, obj, field):
@@ -24,7 +24,7 @@ class Command(BaseCommand):
24
24
  )
25
25
  (
26
26
  parser.add_argument(
27
- "--keepmedia",
27
+ "-k", "--keepmedia",
28
28
  action="store_true",
29
29
  dest="keepmedia",
30
30
  default=False,
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
- duplicable
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 .duplicable import Duplicable, Duplicate
54
+ from .clonable import Clonable, CloneRow
54
55
  from .registrable import Registrable, RegistrableState
55
56
  from .ref import Referrable, StructuredReferrable
56
57