lino 24.11.0__py3-none-any.whl → 25.1.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 (72) hide show
  1. lino/__init__.py +1 -1
  2. lino/core/actions.py +2 -4
  3. lino/core/actors.py +35 -14
  4. lino/core/dbtables.py +12 -12
  5. lino/core/fields.py +27 -18
  6. lino/core/inject.py +9 -2
  7. lino/core/kernel.py +11 -12
  8. lino/core/model.py +25 -39
  9. lino/core/renderer.py +3 -4
  10. lino/core/requests.py +91 -92
  11. lino/core/site.py +5 -46
  12. lino/core/store.py +4 -4
  13. lino/core/tables.py +0 -16
  14. lino/core/utils.py +5 -2
  15. lino/help_texts.py +7 -5
  16. lino/locale/bn/LC_MESSAGES/django.po +1225 -909
  17. lino/locale/de/LC_MESSAGES/django.mo +0 -0
  18. lino/locale/de/LC_MESSAGES/django.po +1748 -1392
  19. lino/locale/django.pot +1150 -909
  20. lino/locale/es/LC_MESSAGES/django.po +1721 -1347
  21. lino/locale/et/LC_MESSAGES/django.po +1267 -954
  22. lino/locale/fr/LC_MESSAGES/django.mo +0 -0
  23. lino/locale/fr/LC_MESSAGES/django.po +1207 -925
  24. lino/locale/nl/LC_MESSAGES/django.po +1261 -942
  25. lino/locale/pt_BR/LC_MESSAGES/django.po +1204 -906
  26. lino/locale/zh_Hant/LC_MESSAGES/django.po +1204 -906
  27. lino/mixins/dupable.py +8 -7
  28. lino/mixins/periods.py +10 -8
  29. lino/modlib/checkdata/__init__.py +1 -1
  30. lino/modlib/comments/choicelists.py +1 -1
  31. lino/modlib/comments/fixtures/demo2.py +4 -1
  32. lino/modlib/comments/mixins.py +9 -10
  33. lino/modlib/comments/models.py +4 -4
  34. lino/modlib/comments/ui.py +11 -2
  35. lino/modlib/dupable/models.py +10 -14
  36. lino/modlib/gfks/mixins.py +8 -1
  37. lino/modlib/jinja/choicelists.py +3 -3
  38. lino/modlib/jinja/renderer.py +2 -0
  39. lino/modlib/languages/fixtures/all_languages.py +4 -6
  40. lino/modlib/linod/mixins.py +3 -2
  41. lino/modlib/memo/mixins.py +17 -215
  42. lino/modlib/memo/models.py +41 -12
  43. lino/modlib/memo/parser.py +7 -3
  44. lino/modlib/notify/mixins.py +35 -34
  45. lino/modlib/periods/choicelists.py +46 -0
  46. lino/modlib/periods/mixins.py +26 -1
  47. lino/modlib/periods/models.py +17 -45
  48. lino/modlib/printing/choicelists.py +3 -3
  49. lino/modlib/publisher/choicelists.py +1 -4
  50. lino/modlib/search/models.py +17 -11
  51. lino/modlib/system/fixtures/__init__.py +0 -0
  52. lino/modlib/system/fixtures/std.py +5 -0
  53. lino/modlib/system/models.py +3 -2
  54. lino/modlib/uploads/__init__.py +10 -1
  55. lino/modlib/uploads/choicelists.py +3 -3
  56. lino/modlib/uploads/mixins.py +49 -51
  57. lino/modlib/uploads/models.py +144 -61
  58. lino/modlib/uploads/ui.py +12 -6
  59. lino/modlib/uploads/utils.py +113 -0
  60. lino/modlib/users/mixins.py +9 -6
  61. lino/modlib/weasyprint/choicelists.py +17 -7
  62. lino/utils/__init__.py +6 -0
  63. lino/utils/choosers.py +21 -8
  64. lino/utils/html.py +1 -1
  65. lino/utils/instantiator.py +9 -0
  66. lino/utils/media.py +2 -3
  67. lino/utils/soup.py +311 -0
  68. {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/METADATA +2 -2
  69. {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/RECORD +72 -67
  70. {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/WHEEL +1 -1
  71. {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/licenses/AUTHORS.rst +0 -0
  72. {lino-24.11.0.dist-info → lino-25.1.0.dist-info}/licenses/COPYING +0 -0
@@ -11,9 +11,13 @@ from django.db import models
11
11
  from django.conf import settings
12
12
  from django.core.exceptions import ValidationError
13
13
  from django.utils.text import format_lazy
14
- from django.utils.html import format_html
14
+ from django.utils.html import format_html, mark_safe
15
15
  from django.utils.translation import pgettext_lazy as pgettext
16
16
 
17
+ from rstgen.sphinxconf.sigal_image import parse_image_spec
18
+ # from rstgen.sphinxconf.sigal_image import Standard, Thumb, Tiny, Wide, Solo, Duo, Trio
19
+ # SMALL_FORMATS = (Thumb, Tiny, Duo, Trio)
20
+
17
21
  from lino.utils.html import E, join_elems
18
22
  from lino.api import dd, rt, _
19
23
  from lino.modlib.gfks.mixins import Controllable
@@ -29,6 +33,7 @@ from lino.modlib.publisher.mixins import Publishable
29
33
  from .actions import CameraStream
30
34
  from .choicelists import Shortcuts, UploadAreas
31
35
  from .mixins import UploadBase
36
+ from .utils import previewer, UploadMediaFile
32
37
 
33
38
  from . import VOLUMES_ROOT
34
39
 
@@ -36,6 +41,7 @@ from . import VOLUMES_ROOT
36
41
  class Volume(Referrable):
37
42
 
38
43
  class Meta:
44
+ abstract = dd.is_abstract_model(__name__, "Volume")
39
45
  app_label = "uploads"
40
46
  verbose_name = _("Library volume")
41
47
  verbose_name_plural = _("Library volumes")
@@ -50,10 +56,8 @@ class Volume(Referrable):
50
56
  return self.ref or self.root_dir
51
57
 
52
58
  def full_clean(self, *args, **kw):
53
- # if self.ref == "uploads":
54
- # raise ValidationError("Invalid reference for a volume.")
55
59
  super().full_clean(*args, **kw)
56
- pth = Path(dd.plugins.uploads.get_volumes_root(), self.ref)
60
+ pth = dd.plugins.uploads.get_volumes_root() / self.ref
57
61
  if pth.exists():
58
62
  if pth.resolve().absolute() != Path(self.root_dir).resolve().absolute():
59
63
  raise ValidationError(
@@ -74,8 +78,9 @@ class Volume(Referrable):
74
78
 
75
79
 
76
80
  class UploadType(BabelNamed):
77
- class Meta(object):
81
+ class Meta:
78
82
  abstract = dd.is_abstract_model(__name__, "UploadType")
83
+ app_label = "uploads"
79
84
  verbose_name = _("Upload type")
80
85
  verbose_name_plural = _("Upload types")
81
86
 
@@ -105,7 +110,8 @@ class UploadType(BabelNamed):
105
110
 
106
111
  class Upload(UploadBase, UserAuthored, Controllable, Publishable):
107
112
 
108
- class Meta(object):
113
+ class Meta:
114
+ app_label = "uploads"
109
115
  abstract = dd.is_abstract_model(__name__, "Upload")
110
116
  verbose_name = _("Upload file")
111
117
  verbose_name_plural = _("Upload files")
@@ -127,41 +133,52 @@ class Upload(UploadBase, UserAuthored, Controllable, Publishable):
127
133
  elif self.file:
128
134
  s = filename_leaf(self.file.name)
129
135
  elif self.library_file:
130
- s = "{}:{}".format(self.volume.ref, self.library_file)
136
+ s = filename_leaf(self.library_file)
137
+ # s = "{}:{}".format(self.volume.ref, self.library_file)
131
138
  else:
132
139
  s = str(self.id)
133
140
  if self.type:
134
141
  s = str(self.type) + " " + s
135
142
  return s
136
143
 
137
- def get_memo_command(self, ar=None):
138
- if dd.is_installed("memo"):
139
- cmd = f"[upload {self.pk}"
140
- if self.description:
141
- cmd += " " + self.description + "]"
142
- else:
143
- cmd += "]"
144
- return cmd
144
+ def get_file_path(self):
145
+ if self.file and self.file.name:
146
+ return self.file.name
147
+ elif self.library_file and self.volume_id and self.volume.ref:
148
+ return VOLUMES_ROOT + "/" + self.volume.ref + "/" + self.library_file
145
149
  return None
146
150
 
147
- def get_file_url(self):
148
- if self.file.name:
149
- return settings.SITE.build_media_url(self.file.name)
150
- if self.library_file and self.volume_id and self.volume.ref:
151
- return settings.SITE.build_media_url(
152
- VOLUMES_ROOT, self.volume.ref, self.library_file)
153
- # return self.volume.base_url + self.library_file
151
+ def get_media_file(self):
152
+ url = self.get_file_path()
153
+ if url is not None:
154
+ return UploadMediaFile(url)
155
+
156
+ def get_create_comment_text(self, ar):
157
+ mf = self.get_media_file()
158
+ if mf is None:
159
+ return super().get_create_comment_text(ar)
160
+ return _("Uploaded an {obj}.").format(obj=mf.get_mimetype_description())
161
+ # or mf.get_image_url() is None:
162
+ # return _("Uploaded {obj}. [{obj.memo_command} {obj.id}].").format(obj=self)
163
+
164
+ def get_memo_command(self, ar=None):
165
+ if dd.is_installed("memo"):
166
+ return f"[{self.memo_command} {self.pk} {self}]"
167
+ # cmd = f"[{self.memo_command} {self.pk}"
168
+ # if self.description:
169
+ # cmd += " " + self.description + "]"
170
+ # else:
171
+ # cmd += "]"
172
+ # return cmd
154
173
  return None
155
174
 
156
175
  def get_real_file_size(self):
157
176
  if self.file:
158
177
  return self.file.size
159
178
  if self.volume_id and self.library_file:
160
- pth = os.path.join(
161
- dd.plugins.uploads.get_volumes_root(),
162
- self.volume.ref, self.library_file)
163
- # pth = os.path.join(settings.MEDIA_ROOT, self.volume.ref, self.library_file)
164
- return os.path.getsize(pth)
179
+ pth = dd.plugins.uploads.get_volumes_root() / self.volume.ref / self.library_file
180
+ return pth.stat().st_size
181
+ # return os.path.getsize(pth)
165
182
 
166
183
  def disabled_fields(self, ar):
167
184
  df = super().disabled_fields(ar)
@@ -184,16 +201,22 @@ class Upload(UploadBase, UserAuthored, Controllable, Publishable):
184
201
 
185
202
  @dd.chooser()
186
203
  def type_choices(self, upload_area):
187
- M = dd.resolve_model("uploads.UploadType")
188
- # logger.info("20140430 type_choices %s", upload_area)
204
+ UploadType = rt.models.uploads.UploadType
189
205
  if upload_area is None:
190
- return M.objects.all()
191
- return M.objects.filter(upload_area=upload_area)
206
+ return UploadType.objects.all()
207
+ return UploadType.objects.filter(upload_area=upload_area)
192
208
 
193
209
  def full_clean(self, *args, **kw):
194
210
  super().full_clean(*args, **kw)
195
211
  if self.type is not None:
196
212
  self.upload_area = self.type.upload_area
213
+ for i in self.check_previews(True):
214
+ pass
215
+
216
+ def check_previews(self, fix):
217
+ p = rt.models.uploads.previewer
218
+ for i in p.check_preview(self, fix):
219
+ yield i
197
220
 
198
221
  def get_gallery_item(self, ar):
199
222
  d = super().get_gallery_item(ar)
@@ -205,40 +228,45 @@ class Upload(UploadBase, UserAuthored, Controllable, Publishable):
205
228
 
206
229
  @dd.htmlbox()
207
230
  def preview(self, ar):
208
- url = self.get_file_url()
209
- if url is None or url.endswith(".pdf"):
231
+ mf = self.get_media_file()
232
+ if mf is None:
210
233
  txt = _("No preview available")
211
234
  return '<p style="text-align: center;padding: 2em;">({})</p>'.format(txt)
212
- return '<img src="{}" style="max-width: 100%; max-height: 20em">'.format(url)
235
+ return '<img src="{}" style="max-width: 100%; max-height: 20em">'.format(mf.get_image_url())
213
236
 
214
237
  @dd.htmlbox(_("Thumbnail"))
215
238
  def thumbnail(self, ar):
216
239
  # url = settings.SITE.build_media_url(self.file.name)
217
- url = self.get_file_url()
218
- return '<img src="{}" style="height: 15ch; max-width: 22.5ch">'.format(url)
219
-
220
- @dd.htmlbox(_("Thumbnail Medium"))
221
- def thumbnail_medium(self, ar):
222
- # url = settings.SITE.build_media_url(self.file.name)
223
- url = self.get_file_url()
224
- return '<img src="{}" style="width: 30ch;">'.format(url)
225
-
226
- @dd.htmlbox(_("Thumbnail Large"))
227
- def thumbnail_large(self, ar):
228
- # url = settings.SITE.build_media_url(self.file.name)
229
- url = self.get_file_url()
230
- return '<img src="{}" style="width: 70ch;">'.format(url)
240
+ mf = self.get_media_file()
241
+ if mf is None:
242
+ return ""
243
+ return '<img src="{}" style="height: 15ch; max-width: 22.5ch">'.format(mf.get_image_url())
231
244
 
232
245
  def as_page(self, ar, **kwargs):
233
246
  yield format_html("<h1>{}</h1>", self)
234
- url = self.get_file_url()
235
- yield format_html('<img src="{}" style="width: 100%;">', url)
247
+ mf = self.get_media_file()
248
+ if mf is not None:
249
+ yield format_html('<img src="{}" style="width: 100%;">', mf.get_image_url())
236
250
  if self.description:
237
251
  yield escape(self.description)
238
252
  if self.source:
239
253
  yield _("Source") + ": "
240
254
  yield ar.obj2htmls(self.source)
241
255
 
256
+ def as_paragraph(self, ar, **kwargs):
257
+ rv = self.memo2html(ar, "")
258
+ # rv = ar.obj2htmls(self)
259
+ # mf = self.get_media_file()
260
+ # if mf is not None:
261
+ # src = mf.get_image_url()
262
+ # if src is not None:
263
+ # url = mf.get_download_url()
264
+ # rv += f'<a href="{url}"><img src="{src}" style="width: 30%;"></a>'
265
+ if self.source:
266
+ rv += format_html(
267
+ " ({}: {})", _("Source"), ar.obj2htmls(self.source))
268
+ return mark_safe(rv)
269
+
242
270
  # def get_choices_text(self, ar, actor, field):
243
271
  # if self.file:
244
272
  # return str(obj) + "&nbsp;<span style=\"float: right;\">" + obj.thumbnail + "</span>"
@@ -246,11 +274,12 @@ class Upload(UploadBase, UserAuthored, Controllable, Publishable):
246
274
 
247
275
 
248
276
  dd.update_field(Upload, "user", verbose_name=_("Uploaded by"))
277
+ dd.update_field(Upload, "owner", verbose_name=_("Attached to"))
249
278
 
250
279
 
251
280
  class UploadChecker(Checker):
252
281
  verbose_name = _("Check metadata of upload files")
253
- model = Upload
282
+ model = "uploads.Upload"
254
283
 
255
284
  def get_checkdata_problems(self, obj, fix=False):
256
285
  if obj.file:
@@ -267,6 +296,8 @@ class UploadChecker(Checker):
267
296
  tpl = "Stored file size {} differs from real file size {}"
268
297
  yield (False, format_lazy(tpl, obj.file_size, file_size))
269
298
 
299
+ for i in obj.check_previews(fix):
300
+ yield i
270
301
 
271
302
  UploadChecker.activate()
272
303
 
@@ -304,7 +335,6 @@ class UploadsFolderChecker(Checker):
304
335
  UploadsFolderChecker.activate()
305
336
 
306
337
 
307
-
308
338
  @dd.receiver(dd.pre_analyze)
309
339
  def before_analyze(sender, **kwargs):
310
340
  # This is the successor for `quick_upload_buttons`.
@@ -313,6 +343,8 @@ def before_analyze(sender, **kwargs):
313
343
  UploadType = sender.models.uploads.UploadType
314
344
  Shortcuts = sender.models.uploads.Shortcuts
315
345
 
346
+ # raise Exception(f"20241112 {UploadType}")
347
+
316
348
  for i in Shortcuts.items():
317
349
 
318
350
  def f(obj, ar):
@@ -341,16 +373,17 @@ def before_analyze(sender, **kwargs):
341
373
  elif n == 1:
342
374
  after_show = ar.get_status()
343
375
  obj = sar.data_iterator[0]
344
- items.append(
345
- sar.renderer.href_button(
346
- obj.get_file_url(),
347
- _("show"),
348
- target="_blank",
349
- icon_name="page_go",
350
- style="vertical-align:-30%;",
351
- title=_("Open the uploaded file in a new browser window"),
376
+ if (mf := obj.get_media_file()) is not None:
377
+ items.append(
378
+ sar.renderer.href_button(
379
+ mf.get_download_url(),
380
+ _("show"),
381
+ target="_blank",
382
+ icon_name="page_go",
383
+ style="vertical-align:-30%;",
384
+ title=_("Open the uploaded file in a new browser window"),
385
+ )
352
386
  )
353
- )
354
387
  after_show.update(record_id=obj.pk)
355
388
  items.append(
356
389
  sar.window_action_button(
@@ -381,4 +414,54 @@ def before_analyze(sender, **kwargs):
381
414
  # logger.info("Installed upload shortcut field %s.%s",
382
415
  # i.model_spec, i.name)
383
416
 
417
+
384
418
  from .ui import *
419
+
420
+ # raise Exception("20241112")
421
+
422
+
423
+ @dd.receiver(dd.post_startup)
424
+ def setup_memo_commands(sender=None, **kwargs):
425
+ # Adds another memo command for Upload
426
+ # See :doc:`/specs/memo`
427
+
428
+ if not sender.is_installed('memo'):
429
+ return
430
+
431
+ def file2html(self, ar, text, **ctx):
432
+ """
433
+ Insert an image tag of the specified upload file.
434
+ """
435
+ mf = self.get_media_file()
436
+ if mf is None:
437
+ return format_html("<em>{}</em>", text or str(self))
438
+ ctx.update(src=mf.get_download_url())
439
+ ctx.update(href=ar.renderer.obj2url(ar, self))
440
+ if not mf.is_image():
441
+ if not text:
442
+ text = str(self)
443
+ ctx.update(text=text)
444
+ tpl = '(<a href="{src}" target="_blank">{text}</a>'
445
+ tpl += '| <a href="{href}">Detail</a>)'
446
+ return format_html(tpl, **ctx)
447
+
448
+ fmt = parse_image_spec(text, **ctx)
449
+ # TODO: When an image is inserted with format "wide", we should not use
450
+ # the thumbnail but the original file. But for a PDF file we must always
451
+ # use the img_src because the download_url is not an image.
452
+ fmt.context.update(src=mf.get_image_url())
453
+ # if isinstance(fmt, SMALL_FORMATS):
454
+ # fmt.context.update(src=img_src)
455
+ # else:
456
+ # print(f"20241116 {fmt} {fmt.context}")
457
+
458
+ if not fmt.context["caption"]:
459
+ fmt.context["caption"] = self.description or str(self)
460
+
461
+ rv = format_html(
462
+ '<a href="{href}" target="_blank"><img src="{src}"'
463
+ + ' style="{style}" title="{caption}"/></a>', **fmt.context)
464
+ return rv
465
+
466
+ mp = sender.plugins.memo.parser
467
+ mp.register_django_model('file', rt.models.uploads.Upload, rnd=file2html)
lino/modlib/uploads/ui.py CHANGED
@@ -66,10 +66,11 @@ class UploadDetail(dd.DetailLayout):
66
66
  """
67
67
 
68
68
  left = """
69
- file user
69
+ file
70
70
  volume:10 library_file:40
71
- upload_area type description
72
- owner
71
+ user owner
72
+ upload_area type
73
+ description
73
74
  source
74
75
  """
75
76
 
@@ -82,6 +83,11 @@ class Uploads(dd.Table):
82
83
  required_roles = dd.login_required(UploadsReader)
83
84
  column_names = "file type user owner description id *"
84
85
  order_by = ["-id"]
86
+ default_display_modes = {
87
+ 70: constants.DISPLAY_MODE_LIST,
88
+ None: constants.DISPLAY_MODE_GALLERY
89
+ }
90
+ # extra_display_modes = {constants.DISPLAY_MODE_LIST, constants.DISPLAY_MODE_GALLERY}
85
91
 
86
92
  detail_layout = "uploads.UploadDetail"
87
93
 
@@ -197,10 +203,10 @@ class AreaUploads(Uploads):
197
203
  # icon_name='application_form',
198
204
  title=_("Edit metadata of the uploaded file."),
199
205
  )
200
- url = m.get_file_url()
201
- if url:
206
+ mf = m.get_media_file()
207
+ if mf:
202
208
  show = ar.renderer.href_button(
203
- url,
209
+ mf.get_download_url(),
204
210
  # u"\u21A7", # DOWNWARDS ARROW FROM BAR (↧)
205
211
  # u"\u21E8",
206
212
  "\u21f2", # SOUTH EAST ARROW TO CORNER (⇲)
@@ -0,0 +1,113 @@
1
+ # -*- coding: UTF-8 -*-
2
+ # Copyright 2010-2024 Rumma & Ko Ltd
3
+ # License: GNU Affero General Public License v3 (see file COPYING for details)
4
+
5
+ from os.path import splitext
6
+ from django.conf import settings
7
+ from django.utils.text import format_lazy
8
+ from lino.api import dd, rt, _
9
+ from lino.utils import needs_update
10
+
11
+ if (with_thumbnails := dd.get_plugin_setting('uploads', 'with_thumbnails', False)):
12
+ from PIL import Image # pip install Pillow
13
+ import pymupdf # pip install PyMuPDF
14
+
15
+ class UploadMediaFile:
16
+
17
+ def __init__(self, url):
18
+ self.url = url
19
+ assert url is not None
20
+ root, suffix = splitext(url)
21
+ self.suffix = suffix.lower()
22
+
23
+ def get_image_name(self):
24
+ if self.suffix not in previewer.PREVIEW_SUFFIXES:
25
+ return None
26
+ if previewer.base_dir is None:
27
+ if self.suffix == ".pdf":
28
+ return None
29
+ return self.url
30
+ url = self.url
31
+ if self.suffix == ".pdf":
32
+ url += ".png"
33
+ return previewer.base_dir + "/" + url
34
+
35
+ def is_image(self):
36
+ # whether this can be rendered in an <img> tag
37
+ if self.get_image_name() is None:
38
+ return False
39
+ return self.suffix in previewer.PREVIEW_SUFFIXES
40
+
41
+ def get_mimetype_description(self):
42
+ if self.suffix == ".pdf":
43
+ return _("PDF file")
44
+ if self.get_image_name():
45
+ return _("picture")
46
+ return _("media file")
47
+
48
+ def get_image_url(self):
49
+ url = self.get_image_name()
50
+ if url is not None:
51
+ return settings.SITE.build_media_url(url)
52
+
53
+ def get_download_url(self):
54
+ return settings.SITE.build_media_url(self.url)
55
+
56
+
57
+ class Previewer:
58
+ # The bare media previewer. It doesn't do any real work.
59
+ base_dir = None
60
+ max_width = None
61
+ PREVIEW_SUFFIXES = {'.png', '.jpg'}
62
+
63
+ def check_preview(self, obj, fix=False):
64
+ return []
65
+
66
+
67
+ class FilePreviewer(Previewer):
68
+ # A media previewer that builds thumbnails in a separate directory tree
69
+ PREVIEW_SUFFIXES = {'.png', '.jpg', '.pdf'}
70
+
71
+ def __init__(self, base_dir=None, max_width=None):
72
+ self.base_dir = base_dir
73
+ self.max_width = max_width
74
+ super().__init__()
75
+
76
+ def check_preview(self, obj, fix=False):
77
+ mf = obj.get_media_file()
78
+ if mf is None:
79
+ return
80
+ if (dst := mf.get_image_name()) is None:
81
+ return
82
+ if dst == mf.url:
83
+ raise Exception("20241113 should never happen")
84
+ return
85
+ src = settings.SITE.media_root / mf.url
86
+ dst = settings.SITE.media_root / dst
87
+
88
+ if needs_update(src, dst):
89
+ yield (True, format_lazy(_("Must build thumbnail for {}"), mf.url))
90
+ if fix:
91
+ if src.suffix.lower() == ".pdf":
92
+ doc = pymupdf.open(src)
93
+ page = doc.load_page(0)
94
+ pixmap = page.get_pixmap(dpi=120)
95
+ pixmap.save(dst)
96
+ return
97
+ with Image.open(src) as im:
98
+ im.thumbnail((self.max_width, self.max_width))
99
+ dst.parent.mkdir(parents=True, exist_ok=True)
100
+ im.save(dst)
101
+
102
+ if with_thumbnails:
103
+ previewer = FilePreviewer("thumbs", 720)
104
+ else:
105
+ previewer = Previewer()
106
+
107
+ # full = Previewer()
108
+ # if with_thumbnails:
109
+ # small = FilePreviewer("thumbs", 720)
110
+ # else:
111
+ # small = full
112
+
113
+ # Lino currently offers only two previewers, "full" and "small"
@@ -159,13 +159,16 @@ class My(dbtables.Table):
159
159
 
160
160
  @classmethod
161
161
  def get_actor_label(self):
162
+ if self._label is not None:
163
+ return self._label
162
164
  if self.model is None:
163
- return self._label or self.__name__
164
- # return self._label or \
165
- # _("My %s") % self.model._meta.verbose_name_plural
166
- return self._label or format_lazy(
167
- _("My {}"), self.model._meta.verbose_name_plural
168
- )
165
+ return self.__name__
166
+ return format_lazy(_("My {}"), self.model._meta.verbose_name_plural)
167
+
168
+ @classmethod
169
+ def setup_request(cls, ar):
170
+ super().setup_request(ar)
171
+ ar.obvious_fields.add("user")
169
172
 
170
173
  @classmethod
171
174
  def param_defaults(self, ar, **kw):
@@ -1,8 +1,9 @@
1
1
  # -*- coding: UTF-8 -*-
2
- # Copyright 2016-2020 Rumma & Ko Ltd
2
+ # Copyright 2016-2024 Rumma & Ko Ltd
3
3
  # License: GNU Affero General Public License v3 (see file COPYING for details)
4
4
 
5
5
  import os
6
+ from pathlib import Path
6
7
  from copy import copy
7
8
 
8
9
  try:
@@ -10,11 +11,20 @@ try:
10
11
  except ImportError:
11
12
  HTML = None
12
13
 
14
+ try:
15
+ import bulma
16
+ from weasyprint import CSS
17
+ BULMA_CSS = Path(bulma.__file__).parent / "static/bulma/css/style.min.css"
18
+ assert BULMA_CSS.exists()
19
+ except ImportError:
20
+ BULMA_CSS = None
21
+
22
+
23
+
13
24
  from django.conf import settings
14
25
  from django.utils import translation
15
26
 
16
27
  from lino.api import dd
17
-
18
28
  from lino.modlib.jinja.choicelists import JinjaBuildMethod
19
29
  from lino.modlib.printing.choicelists import BuildMethods
20
30
 
@@ -29,17 +39,17 @@ class WeasyHtmlBuildMethod(WeasyBuildMethod):
29
39
  target_ext = ".html"
30
40
  name = "weasy2html"
31
41
 
32
- def html2file(self, html, filename):
33
- open(filename, "w").write(html)
34
-
35
42
 
36
43
  class WeasyPdfBuildMethod(WeasyBuildMethod):
37
44
  target_ext = ".pdf"
38
45
  name = "weasy2pdf"
39
46
 
40
- def html2file(self, html, filename):
47
+ def html2file(self, html, filename, context):
41
48
  pdf = HTML(string=html)
42
- pdf.write_pdf(filename)
49
+ if BULMA_CSS and context.get('use_bulma_css', False):
50
+ pdf.write_pdf(filename, stylesheets=[CSS(filename=BULMA_CSS)])
51
+ else:
52
+ pdf.write_pdf(filename)
43
53
 
44
54
 
45
55
  add = BuildMethods.add_item_instance
lino/utils/__init__.py CHANGED
@@ -98,6 +98,12 @@ def buildurl(root, *args, **kw):
98
98
  return url
99
99
 
100
100
 
101
+ def needs_update(src, dest):
102
+ if dest.exists() and dest.stat().st_mtime >= src.stat().st_mtime:
103
+ return False
104
+ return True
105
+
106
+
101
107
  class AttrDict(dict):
102
108
  """
103
109
  Dictionary-like helper object.
lino/utils/choosers.py CHANGED
@@ -108,6 +108,7 @@ class ChoiceConverter(Converter):
108
108
 
109
109
  def convert(self, **kw):
110
110
  value = kw.get(self.field.name)
111
+ # print(f"20241203 convert {self.field.name}")
111
112
 
112
113
  if value is not None:
113
114
  if not isinstance(value, self.field.choicelist.item_class):
@@ -190,7 +191,16 @@ class ManyToManyConverter(LookupConverter):
190
191
 
191
192
  def make_converter(f, lookup_fields={}):
192
193
  from lino.core.gfks import GenericForeignKey
194
+ from lino.core.fields import VirtualField
193
195
 
196
+ # if f.name == 'rating_type':
197
+ # print(f"20241203 {f.__class__}")
198
+
199
+ # selector = f
200
+
201
+ if isinstance(f, VirtualField):
202
+ # print(f"20241203 {f.name} {f.return_type.name}")
203
+ f = f.return_type
194
204
  if isinstance(f, models.ForeignKey):
195
205
  return ForeignKeyConverter(f, lookup_fields.get(f.name, "pk"))
196
206
  if isinstance(f, GenericForeignKey):
@@ -206,8 +216,7 @@ def make_converter(f, lookup_fields={}):
206
216
  from lino.core import choicelists
207
217
 
208
218
  if isinstance(f, choicelists.ChoiceListField):
209
- # if f.name == 'p_book':
210
- # print "20131012 b", f
219
+ # print(f"20241203c {f.__class__}")
211
220
  return ChoiceConverter(f)
212
221
 
213
222
 
@@ -244,18 +253,22 @@ class Chooser(FieldChooser):
244
253
  from lino.core.gfks import is_foreignkey
245
254
  from lino.core.choicelists import ChoiceListField
246
255
 
247
- if isinstance(field, ChoiceListField):
256
+ selector = field
257
+ if isinstance(field, fields.VirtualField):
258
+ selector = field.return_type
259
+
260
+ if isinstance(selector, ChoiceListField):
248
261
  self.simple_values = getattr(meth, "simple_values", False)
249
262
  self.instance_values = getattr(meth, "instance_values", True)
250
263
  self.force_selection = getattr(
251
264
  meth, "force_selection", self.force_selection
252
265
  )
253
- elif is_foreignkey(field):
254
- pass
255
- elif isinstance(field, fields.VirtualField) and isinstance(
256
- field.return_type, models.ForeignKey
257
- ):
266
+ elif is_foreignkey(selector):
258
267
  pass
268
+ # elif isinstance(field, fields.VirtualField) and isinstance(
269
+ # field.return_type, models.ForeignKey
270
+ # ):
271
+ # pass
259
272
  else:
260
273
  self.simple_values = getattr(meth, "simple_values", False)
261
274
  self.instance_values = getattr(meth, "instance_values", False)
lino/utils/html.py CHANGED
@@ -11,7 +11,7 @@ from etgen.html import E, to_rst, fromstring, iselement, join_elems, forcetext,
11
11
 
12
12
  # from etgen.html import tostring as et_tostring
13
13
  from html2text import HTML2Text
14
- from django.utils.html import SafeString, mark_safe, escape
14
+ from django.utils.html import SafeString, mark_safe, escape, format_html
15
15
  # from lino.utils import tostring
16
16
 
17
17
  SAFE_EMPTY = mark_safe("")
@@ -210,6 +210,15 @@ def create_and_get(model, **kw):
210
210
  o = create(model, **kw)
211
211
  return model.objects.get(pk=o.pk)
212
212
 
213
+ def make_if_needed(model, **values):
214
+ qs = model.objects.filter(**values)
215
+ if qs.count() == 1:
216
+ pass # ok, nothing to do
217
+ elif qs.count() == 0:
218
+ return model(**values)
219
+ else:
220
+ raise Exception(f"Multiple {model._meta.verbose_name_plural} for {values}")
221
+
213
222
 
214
223
  def _test():
215
224
  import doctest