lino 25.7.1__py3-none-any.whl → 25.7.3__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 (55) hide show
  1. lino/__init__.py +1 -1
  2. lino/api/doctest.py +41 -12
  3. lino/core/__init__.py +0 -2
  4. lino/core/actions.py +15 -7
  5. lino/core/actors.py +2 -162
  6. lino/core/atomizer.py +9 -8
  7. lino/core/auth/utils.py +9 -1
  8. lino/core/callbacks.py +2 -2
  9. lino/core/elems.py +1 -1
  10. lino/core/fields.py +3 -1
  11. lino/core/kernel.py +14 -18
  12. lino/core/layouts.py +5 -7
  13. lino/core/model.py +12 -3
  14. lino/core/plugin.py +1 -1
  15. lino/core/renderer.py +1 -1
  16. lino/core/requests.py +3 -4
  17. lino/core/site.py +1 -1
  18. lino/core/store.py +3 -3
  19. lino/core/utils.py +20 -17
  20. lino/help_texts.py +10 -7
  21. lino/mixins/__init__.py +3 -2
  22. lino/mixins/{duplicable.py → clonable.py} +45 -50
  23. lino/mixins/dupable.py +2 -2
  24. lino/mixins/registrable.py +7 -5
  25. lino/mixins/sequenced.py +12 -14
  26. lino/modlib/dupable/models.py +2 -2
  27. lino/modlib/extjs/views.py +7 -0
  28. lino/modlib/linod/__init__.py +1 -1
  29. lino/modlib/memo/__init__.py +1 -2
  30. lino/modlib/notify/api.py +5 -0
  31. lino/modlib/office/roles.py +0 -1
  32. lino/modlib/printing/actions.py +2 -6
  33. lino/modlib/printing/choicelists.py +6 -6
  34. lino/modlib/printing/mixins.py +4 -4
  35. lino/modlib/publisher/__init__.py +21 -30
  36. lino/modlib/publisher/models.py +3 -1
  37. lino/modlib/publisher/views.py +4 -11
  38. lino/modlib/summaries/mixins.py +6 -4
  39. lino/modlib/users/actions.py +5 -0
  40. lino/modlib/weasyprint/__init__.py +9 -0
  41. lino/modlib/weasyprint/choicelists.py +14 -9
  42. lino/modlib/weasyprint/config/weasyprint/base.weasy.html +15 -13
  43. lino/sphinxcontrib/__init__.py +1 -1
  44. lino/sphinxcontrib/actordoc.py +1 -1
  45. lino/utils/diag.py +2 -2
  46. lino/utils/instantiator.py +21 -1
  47. {lino-25.7.1.dist-info → lino-25.7.3.dist-info}/METADATA +1 -1
  48. {lino-25.7.1.dist-info → lino-25.7.3.dist-info}/RECORD +51 -55
  49. lino/modlib/forms/__init__.py +0 -51
  50. lino/modlib/forms/models.py +0 -0
  51. lino/modlib/forms/renderer.py +0 -74
  52. lino/modlib/forms/views.py +0 -311
  53. {lino-25.7.1.dist-info → lino-25.7.3.dist-info}/WHEEL +0 -0
  54. {lino-25.7.1.dist-info → lino-25.7.3.dist-info}/licenses/AUTHORS.rst +0 -0
  55. {lino-25.7.1.dist-info → lino-25.7.3.dist-info}/licenses/COPYING +0 -0
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",
@@ -994,6 +1002,7 @@ LINO_MODEL_ATTRIBS = (
994
1002
  "_lino_tables",
995
1003
  "show_in_site_search",
996
1004
  "allow_merge_action",
1005
+ "get_overview_elems",
997
1006
  )
998
1007
 
999
1008
 
lino/core/plugin.py CHANGED
@@ -65,7 +65,7 @@ class Plugin:
65
65
  raise Exception("%s has no attribute %s" % (self, k))
66
66
  setattr(self, k, v)
67
67
 
68
- def get_required_plugins(self):
68
+ def get_needed_plugins(self):
69
69
  return self.needs_plugins
70
70
 
71
71
  def get_used_libs(self, html=None):
lino/core/renderer.py CHANGED
@@ -498,7 +498,7 @@ class HtmlRenderer(Renderer):
498
498
  return E.p(*buttons)
499
499
 
500
500
  def get_home_url(self, *args, **kw):
501
- return settings.SITE.kernel.web_front_ends[0].build_plain_url(*args, **kw)
501
+ return settings.SITE.kernel.editing_front_end.build_plain_url(*args, **kw)
502
502
 
503
503
  def obj2url(self, ar, obj):
504
504
  ba = obj.get_detail_action(ar)
lino/core/requests.py CHANGED
@@ -1842,10 +1842,9 @@ class ActionRequest(BaseRequest):
1842
1842
  pv = self.actor.param_defaults(self)
1843
1843
  for k in pv.keys():
1844
1844
  if k not in self.actor.parameters:
1845
- raise Exception(
1846
- "%s.param_defaults() returned invalid keyword %r"
1847
- % (self.actor, k)
1848
- )
1845
+ msg = f"{self.actor} param_defaults() returned keyword {k}"
1846
+ msg += f" (must be one of {sorted(self.actor.parameters.keys())})"
1847
+ raise Exception(msg)
1849
1848
 
1850
1849
  # New since 20120913. E.g. newcomers.Newcomers is a
1851
1850
  # simple pcsw.Clients with
lino/core/site.py CHANGED
@@ -862,7 +862,7 @@ class Site(object):
862
862
  needed_by = ip
863
863
  # while needed_by.needed_by is not None:
864
864
  # needed_by = needed_by.needed_by
865
- for dep in ip.get_required_plugins():
865
+ for dep in ip.get_needed_plugins():
866
866
  k2 = dep.rsplit(".")[-1]
867
867
  if k2 not in self.plugins:
868
868
  install_plugin(dep, needed_by=needed_by)
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
@@ -664,10 +664,11 @@ class Parametrizable:
664
664
  parameters = None
665
665
  params_layout = None
666
666
  params_panel_hidden = True
667
- params_panel_pos = "top" # allowed values "top", "bottom", "left" and "right"
667
+ params_panel_pos = "bottom" # allowed values "top", "bottom", "left" and "right"
668
668
  use_detail_param_panel = False
669
669
 
670
670
  _params_layout_class = NotImplementedError
671
+ _field_actions = dict()
671
672
 
672
673
  def get_window_layout(self, actor):
673
674
  return self.params_layout
@@ -809,29 +810,30 @@ def error2str(self, e):
809
810
 
810
811
 
811
812
  def resolve_fields_list(model, k, collection_type=tuple, default=None):
812
- qsf = getattr(model, k)
813
- if qsf is None:
813
+ value = getattr(model, k)
814
+ if value is None:
814
815
  setattr(model, k, default)
815
- elif qsf == default:
816
- pass
817
- elif isinstance(qsf, collection_type):
818
- pass
819
- 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)):
820
824
  lst = []
821
- for n in qsf.split():
825
+ for n in value:
822
826
  f = model.get_data_elem(n)
823
827
  if f is None:
824
- msg = "Invalid field {} in {} of {}"
825
- msg = msg.format(n, k, model)
828
+ msg = f"Invalid field {n} in {k} of {model}"
826
829
  raise Exception(msg)
827
830
  lst.append(f)
828
831
  setattr(model, k, collection_type(lst))
829
832
  # fields.fields_list(model, model.quick_search_fields))
830
833
  else:
831
834
  raise ChangedAPI(
832
- "{0}.{1} must be None or a string "
833
- "of space-separated field names (not {2})".format(model, k, qsf)
834
- )
835
+ f"{model}.{k} must be None or a string "
836
+ f"of space-separated field names (not {value})")
835
837
 
836
838
 
837
839
  def class_dict_items(cl, exclude=None):
@@ -1238,11 +1240,12 @@ def register_params(cls):
1238
1240
  cls.params_layout = cls._params_layout_class.join_str.join(
1239
1241
  cls.parameters.keys()
1240
1242
  )
1243
+ if cls.params_layout is not None:
1241
1244
  install_layout(cls, "params_layout", cls._params_layout_class)
1242
1245
 
1243
- # e.g. accounting.ByJournal is just a mixin but provides a default value for its children
1244
- elif cls.params_layout is not None:
1245
- raise Exception("{} has a params_layout but no parameters".format(cls))
1246
+ # # e.g. accounting.ByJournal is just a mixin but provides a default value for its children
1247
+ # elif cls.params_layout is not None:
1248
+ # raise Exception("{} has a params_layout but no parameters".format(cls))
1246
1249
 
1247
1250
  # if isinstance(cls, type) and cls.__name__.endswith("Users"):
1248
1251
  # # if isinstance(cls, type) and cls.model is not None and cls.model.__name__ == "User":
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:"""),
@@ -311,8 +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
- 'lino.core.model.Model.disabled_fields' : _("""Return a set of field names that should be disabled (i.e. not editable) for this database object."""),
316
312
  'lino.core.model.Model.__str__' : _("""Return a translatable text that describes this database row."""),
317
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."""),
318
314
  'lino.core.model.Model.get_str_words' : _("""Yield a series of words that describe this database row in plain text."""),
@@ -705,15 +701,22 @@ help_texts = {
705
701
  'lino.modlib.weasyprint.WeasyHtmlBuildMethod' : _("""Renders the input template and returns the unmodified output as plain HTML."""),
706
702
  'lino.modlib.weasyprint.WeasyPdfBuildMethod' : _("""Like WeasyBuildMethod, but the rendered HTML is then passed through weasyprint which converts from HTML to PDF."""),
707
703
  'lino.core.model.Model' : _("""Lino extension of Django’s database model. This is a subclass of Django’s Model class (django.db.models.Model)."""),
704
+ 'lino.core.model.Model.__init__' : _("""The first positional argument is the optional label, other arguments should be specified as keywords and can be any of the existing class attributes."""),
708
705
  'lino.core.model.Model.overview' : _("""A multi-paragraph representation of this database row."""),
709
706
  'lino.core.model.Model.navigation_panel' : _("""A virtual field that displays the navigation panel for this row. This may be included in a detail layout, usually either on the left or the right side with full height."""),
710
707
  'lino.core.model.Model.workflow_buttons' : _("""Shows the current workflow state of this database row and a list of available workflow actions."""),
711
708
  'lino.core.model.Model.workflow_state_field' : _("""Optional default value for the workflow_state_field of all data tables based on this model."""),
712
709
  'lino.core.model.Model.workflow_owner_field' : _("""Optional default value for workflow_owner_field on all data tables based on this model."""),
710
+ 'lino.core.model.Model.disabled_fields' : _("""Return a set of field names that should be disabled (i.e. not editable) for this database object."""),
713
711
  'lino.core.model.Model.FOO_changed' : _("""Called when field FOO of an instance of this model has been modified through the user interface."""),
714
712
  'lino.core.model.Model.FOO_choices' : _("""Return a queryset or list of allowed choices for field FOO."""),
715
713
  'lino.core.model.Model.create_FOO_choice' : _("""For every field named “FOO” for which a chooser exists, if the model also has a method called “create_FOO_choice”, then this chooser will be a learning chooser. That is, users can enter text into the combobox, and Lino will create a new database object from it."""),
716
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."""),
717
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."""),
718
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."""),
719
722
  }
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
 
@@ -1,13 +1,10 @@
1
1
  # -*- coding: UTF-8 -*-
2
2
  # Copyright 2012-2025 Rumma & Ko Ltd
3
3
  # License: GNU Affero General Public License v3 (see file COPYING for details)
4
- """Defines the model mixin :class:`Duplicable`. "duplicable"
5
- [du'plikəblə] means "able to produce a duplicate
6
- ['duplikət], ['du:plikeit]".
7
-
8
- """
4
+ "See :doc:`/dev/duplicate`."
9
5
 
10
6
  from django.utils.translation import gettext_lazy as _
7
+ from django.utils.text import format_lazy
11
8
 
12
9
  from lino import logger
13
10
  from lino.core import actions
@@ -16,16 +13,12 @@ from lino.core.diff import ChangeWatcher
16
13
  # from lino.core.roles import Expert
17
14
 
18
15
 
19
- class Duplicate(actions.Action):
20
- """Duplicate the selected row.
21
-
22
- This will call :meth:`lino.core.model.Model.on_duplicate` on the
23
- new object and on related objects.
16
+ class CloneRow(actions.Action):
17
+ "See :doc:`/dev/duplicate`."
24
18
 
25
- """
26
-
27
- label = "\u2687" # ⚇ "white circle with two dots"
28
- # label = _("Duplicate")
19
+ # button_text = "" # \u2687 "white circle with two dots"
20
+ button_text = "🗗" # OVERLAP (U+1F5D7)
21
+ label = _("Duplicate")
29
22
  # icon_name = 'arrow_divide'
30
23
  sort_index = 11
31
24
  show_in_workflow = False
@@ -34,6 +27,11 @@ class Duplicate(actions.Action):
34
27
 
35
28
  # required_roles = set([Expert])
36
29
 
30
+ def get_help_text(self, ba):
31
+ return format_lazy(
32
+ _("Insert a new {} as a copy of this."),
33
+ ba.actor.model._meta.verbose_name)
34
+
37
35
  def get_action_view_permission(self, actor, user_type):
38
36
  # the action is readonly because it doesn't write to the
39
37
  # current object, but since it does modify the database we
@@ -48,30 +46,9 @@ class Duplicate(actions.Action):
48
46
  return super().get_action_view_permission(actor, user_type)
49
47
 
50
48
  def run_from_code(self, ar, **known_values):
49
+ # CloneSequenced uses known_values to set seqno to seqno + 1
51
50
  obj = ar.selected_rows[0]
52
- related = []
53
- for m, fk in obj._lino_ddh.fklist:
54
- # print(fk.name, m.allow_cascaded_delete, m.allow_cascaded_copy, obj)
55
- if fk.name in m.allow_cascaded_delete or fk.name in m.allow_cascaded_copy:
56
- related.append((fk, m.objects.filter(**{fk.name: obj})))
57
-
58
- fields_list = obj._meta.concrete_fields
59
- if True:
60
- for f in fields_list:
61
- if not f.primary_key:
62
- if f.name not in known_values:
63
- known_values[f.name] = getattr(obj, f.name)
64
- new = obj.__class__(**known_values)
65
- # 20120704 create_instances causes fill_from_person() on a
66
- # CBSS request.
67
- else:
68
- # doesn't seem to want to work
69
- new = obj
70
- for f in fields_list:
71
- if f.primary_key:
72
- # causes Django to consider this an unsaved instance
73
- setattr(new, f.name, None)
74
-
51
+ new, related = obj.duplication_plan(**known_values)
75
52
  new.on_duplicate(ar, None)
76
53
  new.full_clean()
77
54
  new.save(force_insert=True)
@@ -94,11 +71,9 @@ class Duplicate(actions.Action):
94
71
 
95
72
  logger.info("%s has been duplicated to %s (%d related rows)",
96
73
  obj, new, relcount)
97
-
98
74
  return new
99
75
 
100
76
  def run_from_ui(self, ar, **kw):
101
- """This actually runs the action."""
102
77
 
103
78
  if (msg := ar.actor.model.disable_create(ar)) is not None:
104
79
  ar.error(msg)
@@ -126,18 +101,38 @@ class Duplicate(actions.Action):
126
101
  )
127
102
 
128
103
 
129
- class Duplicable(model.Model):
130
- """Adds a row action "Duplicate", which duplicates (creates a clone
131
- of) the object it was called on.
104
+ class Clonable(model.Model):
105
+ "See :doc:`/dev/duplicate`."
132
106
 
133
- Subclasses may override :meth:`lino.core.model.Model.on_duplicate`
134
- to customize the default behaviour, which is to copy all fields
135
- except the primary key and all related objects that are
136
- duplicable.
107
+ class Meta:
108
+ abstract = True
137
109
 
138
- """
110
+ clone_row = CloneRow()
139
111
 
140
- class Meta(object):
141
- abstract = True
112
+ def duplication_plan(obj, **known_values):
113
+ related = []
114
+ for m, fk in obj._lino_ddh.fklist:
115
+ # if fk.name in m.suppress_cascaded_copy:
116
+ # continue
117
+ # print(fk.name, m.allow_cascaded_delete, m.allow_cascaded_copy, obj)
118
+ # if fk.name in m.allow_cascaded_delete or fk.name in m.allow_cascaded_copy:
119
+ if fk.name in m.allow_cascaded_copy:
120
+ related.append((fk, m.objects.filter(**{fk.name: obj})))
142
121
 
143
- duplicate = Duplicate()
122
+ fields_list = obj._meta.concrete_fields
123
+ if True:
124
+ for f in fields_list:
125
+ if not f.primary_key:
126
+ if f.name not in known_values:
127
+ known_values[f.name] = getattr(obj, f.name)
128
+ new = obj.__class__(**known_values)
129
+ # 20120704 create_instances causes fill_from_person() on a
130
+ # CBSS request.
131
+ else:
132
+ # doesn't seem to want to work
133
+ new = obj
134
+ for f in fields_list:
135
+ if f.primary_key:
136
+ # causes Django to consider this an unsaved instance
137
+ setattr(new, f.name, None)
138
+ return new, related
lino/mixins/dupable.py CHANGED
@@ -5,8 +5,8 @@
5
5
  Defines the :class:`Dupable` model mixin and related functionality
6
6
  to assist users in finding unwanted duplicate database records.
7
7
 
8
- Don't mix up this module with :mod:`lino.mixins.duplicable`. Models
9
- are "duplicable" if users may *want* to duplicate some instance
8
+ Don't mix up this module with :mod:`lino.mixins.`. Models
9
+ are "" if users may *want* to duplicate some instance
10
10
  thereof, while "dupable" implies that the duplicates are *unwanted*.
11
11
  To dupe *somebody* means "to make a dupe of; deceive; delude; trick."
12
12
  (`reference.com <https://dictionary.reference.com/browse/dupe>`_), and
@@ -118,12 +118,14 @@ class Registrable(model.Model):
118
118
  # yield 'date'
119
119
 
120
120
  def disabled_fields(self, ar):
121
+ rv = super().disabled_fields(ar)
121
122
  if not self.state.is_editable:
122
123
  # return self._registrable_fields
123
124
  # Copy _registrable_fields otherwise _registrable_fields get
124
125
  # modified as more disabled fields are added to the set.
125
- return self._registrable_fields.copy()
126
- return super().disabled_fields(ar)
126
+ # return self._registrable_fields.copy()
127
+ rv |= self._registrable_fields
128
+ return rv
127
129
 
128
130
  def get_row_permission(self, ar, state, ba):
129
131
  """Only rows in an editable state may be edited.
@@ -141,7 +143,7 @@ class Registrable(model.Model):
141
143
  # if not ar.bound_action.action.readonly:
142
144
  if not ba.action.readonly:
143
145
  return False
144
- return super(Registrable, self).get_row_permission(ar, state, ba)
146
+ return super().get_row_permission(ar, state, ba)
145
147
 
146
148
  def register(self, ar):
147
149
  """
@@ -191,7 +193,7 @@ class Registrable(model.Model):
191
193
 
192
194
  @classmethod
193
195
  def get_simple_parameters(cls):
194
- for p in super(Registrable, cls).get_simple_parameters():
196
+ for p in super().get_simple_parameters():
195
197
  yield p
196
198
  # if isinstance(cls.workflow_state_field, str):
197
199
  # raise Exception("Unresolved workflow state field in {}".format(cls))
@@ -201,4 +203,4 @@ class Registrable(model.Model):
201
203
 
202
204
  def on_duplicate(self, ar, master):
203
205
  self.state = self.workflow_state_field.choicelist.draft
204
- super(Registrable, self).on_duplicate(ar, master)
206
+ super().on_duplicate(ar, master)
lino/mixins/sequenced.py CHANGED
@@ -24,7 +24,7 @@ from lino.utils.html import E
24
24
  from lino.utils import AttrDict
25
25
  from lino.utils import join_elems
26
26
 
27
- from .duplicable import Duplicable, Duplicate
27
+ from lino.mixins.clonable import Clonable, CloneRow
28
28
 
29
29
 
30
30
  class MoveByN(actions.Action):
@@ -167,8 +167,7 @@ class MoveDown(actions.Action):
167
167
  ar.success(**kw)
168
168
 
169
169
 
170
- class DuplicateSequenced(Duplicate):
171
- """Duplicate this row."""
170
+ class CloneSequenced(CloneRow):
172
171
 
173
172
  def run_from_code(self, ar, **kw):
174
173
  obj = ar.selected_rows[0]
@@ -184,7 +183,7 @@ class DuplicateSequenced(Duplicate):
184
183
  return super().run_from_code(ar, **kw)
185
184
 
186
185
 
187
- class Sequenced(Duplicable):
186
+ class Sequenced(Clonable):
188
187
  """Mixin for models that have a field :attr:`seqno` containing a
189
188
  "sequence number".
190
189
 
@@ -192,12 +191,11 @@ class Sequenced(Duplicable):
192
191
 
193
192
  The sequence number of this item with its parent.
194
193
 
195
- .. method:: duplicate
194
+ .. method:: clone_row
196
195
 
197
- Create a duplicate of this object and insert the new object
198
- below this one.
196
+ Create a duplicate of this row and insert the new row below this one.
199
197
 
200
- Implemented by :class:`DuplicateSequenced`
198
+ Implemented by :class:`CloneSequenced`
201
199
 
202
200
  .. attribute:: move_up
203
201
 
@@ -212,28 +210,28 @@ class Sequenced(Duplicable):
212
210
  Displays buttons for certain actions on this row:
213
211
 
214
212
  - :attr:`move_up` and :attr:`move_down`
215
- - duplicate
213
+ - clone_row
216
214
 
217
215
  .. attribute:: move_by_n
218
216
 
219
217
  """
220
218
 
221
- move_action_names = ("move_up", "move_down", "duplicate")
219
+ move_action_names = ("move_up", "move_down", "clone_row")
222
220
  """The names of the actions to display in the `move_buttons`
223
221
  column.
224
222
 
225
223
  Overridden by :class:`lino.modlib.dashboard.Widget` where the
226
- duplicate button would be irritating.
224
+ clone_row button would be irritating.
227
225
 
228
226
  """
229
227
 
230
- class Meta(object):
228
+ class Meta:
231
229
  abstract = True
232
230
  ordering = ["seqno"]
233
231
 
234
232
  seqno = models.IntegerField(_("No."), blank=True, null=False)
235
233
 
236
- duplicate = DuplicateSequenced()
234
+ clone_row = CloneSequenced()
237
235
 
238
236
  move_up = MoveUp()
239
237
  move_down = MoveDown()
@@ -352,7 +350,7 @@ Sequenced.set_widget_options("move_buttons", width=5)
352
350
  Sequenced.set_widget_options("seqno", hide_sum=True)
353
351
 
354
352
 
355
- class Hierarchical(Duplicable):
353
+ class Hierarchical(Clonable):
356
354
  """Model mixin for things that have a "parent" and "siblings".
357
355
 
358
356
  Pronounciation: [hai'ra:kikl]
@@ -4,8 +4,8 @@
4
4
  """Defines the :class:`Dupable` model mixin and related functionality
5
5
  to assist users in finding unwanted duplicate database records.
6
6
 
7
- Don't mix up this module with :mod:`lino.mixins.duplicable`. Models
8
- are "duplicable" if users may *want* to duplicate some instance
7
+ Don't mix up this module with :mod:`lino.mixins.`. Models
8
+ are "" if users may *want* to duplicate some instance
9
9
  thereof, while "dupable" implies that the duplicates are *unwanted*.
10
10
  To dupe *somebody* means "to make a dupe of; deceive; delude; trick."
11
11
  (`reference.com <https://dictionary.reference.com/browse/dupe>`_), and
@@ -463,6 +463,13 @@ class ApiElement(View):
463
463
  else:
464
464
  datarec = ar.elem2rec_detailed(elem)
465
465
  datarec.update(**vm)
466
+
467
+ fa = dict()
468
+ for k, lst in rpt._field_actions.items():
469
+ fa[k] = [ar.row_action_button(elem, ba) for ba in lst]
470
+ if fa:
471
+ datarec.update(field_actions=fa)
472
+
466
473
  return json_response(datarec)
467
474
 
468
475
  after_show = ar.get_status(record_id=pk)
@@ -50,7 +50,7 @@ class Plugin(ad.Plugin):
50
50
  m = m.add_menu(mg.app_label, mg.verbose_name)
51
51
  m.add_action("linod.Procedures")
52
52
 
53
- def get_required_plugins(self):
53
+ def get_needed_plugins(self):
54
54
  # We don't use needs_plugins because it depends on use_channels. We must
55
55
  # not install the plugin when the Python package isn't installed because
56
56
  # otherwise `pm install` fails with ModuleNotFoundError: No module named
@@ -50,7 +50,7 @@ class Plugin(ad.Plugin):
50
50
  """The front end to use when writing previews.
51
51
 
52
52
  If this is `None`, Lino will use the default :term:`front end`
53
- (:attr:`lino.core.site.Site.web_front_ends`).
53
+ (:attr:`lino.core.site.Site.editing_front_end`).
54
54
 
55
55
  Used on sites that are available through more than one web front ends. The
56
56
  :term:`server administrator` must then decide which front end is the primary
@@ -115,7 +115,6 @@ class Plugin(ad.Plugin):
115
115
  def post_site_startup(self, site):
116
116
  if self.front_end is None:
117
117
  self.front_end = site.kernel.editing_front_end
118
- # web_front_ends[0]
119
118
  else:
120
119
  self.front_end = site.plugins.resolve(self.front_end)
121
120
 
lino/modlib/notify/api.py CHANGED
@@ -8,6 +8,7 @@ from django.conf import settings
8
8
  from django.utils.timezone import now
9
9
  from lino.api import rt, dd
10
10
  from lino.modlib.linod.utils import CHANNEL_NAME, BROADCAST_CHANNEL, get_channel_name
11
+ from lino_xl.lib.matrix.utils import send_notification_to_matrix_room, send_notification_direct
11
12
 
12
13
  NOTIFICATION = "NOTIFICATION"
13
14
  CHAT = "CHAT"
@@ -67,6 +68,10 @@ def send_notification(
67
68
  if user is None or settings.SITE.loading_from_dump:
68
69
  return
69
70
 
71
+ if dd.is_installed("matrix") and dd.plugins.matrix.credentials_file is not None:
72
+ # send_notification_to_matrix_room(f"{subject}\n\n{body}")
73
+ send_notification_direct(f"{subject}\n\n{body}", user)
74
+
70
75
  if dd.get_plugin_setting("linod", "use_channels"):
71
76
  # importing channels at module level would cause certain things to fail
72
77
  # when channels isn't installed, e.g. `manage.py prep` in `lino_book.projects.workflows`.
@@ -1,7 +1,6 @@
1
1
  # Copyright 2015-2023 Rumma & Ko Ltd
2
2
  # License: GNU Affero General Public License v3 (see file COPYING for details)
3
3
 
4
- from lino.core.roles import UserRole
5
4
  from lino.modlib.uploads.roles import UploadsReader
6
5
 
7
6