Upgrade django-pipeline to fix conflict with sentry-sdk.
[wolnelektury.git] / src / picture / models.py
1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
3 #
4 from django.db import models, transaction
5 import catalogue.models
6 from sorl.thumbnail import ImageField
7 from django.conf import settings
8 from django.contrib.contenttypes.fields import GenericRelation
9 from django.core.files.storage import FileSystemStorage
10 from django.urls import reverse
11 from slugify import slugify
12
13 from catalogue.models.tag import prefetched_relations
14 from catalogue.utils import split_tags
15 from picture import tasks
16 from wolnelektury.utils import cached_render, clear_cached_renders
17 from io import BytesIO
18 import itertools
19 import json
20 import logging
21 import re
22
23 from PIL import Image
24
25 from django.utils.translation import ugettext_lazy as _
26 from newtagging import managers
27 from os import path
28
29
30 picture_storage = FileSystemStorage(location=path.join(
31         settings.MEDIA_ROOT, 'pictures'),
32         base_url=settings.MEDIA_URL + "pictures/")
33
34
35 class PictureArea(models.Model):
36     picture = models.ForeignKey('picture.Picture', models.CASCADE, related_name='areas')
37     area = models.TextField(_('area'), default='{}', editable=False)
38     kind = models.CharField(
39         _('kind'), max_length=10, blank=False, null=False, db_index=True,
40         choices=(('thing', _('thing')), ('theme', _('theme'))))
41
42     objects = models.Manager()
43     tagged = managers.ModelTaggedItemManager(catalogue.models.Tag)
44     tags = managers.TagDescriptor(catalogue.models.Tag)
45     tag_relations = GenericRelation(catalogue.models.Tag.intermediary_table_model)
46
47     short_html_url_name = 'picture_area_short'
48
49     @classmethod
50     def rectangle(cls, picture, kind, coords):
51         pa = PictureArea()
52         pa.picture = picture
53         pa.kind = kind
54         pa.area = json.dumps(coords)
55         return pa
56
57     def get_area_json(self):
58         return json.loads(self.area)
59
60     @cached_render('picture/picturearea_short.html')
61     def midi_box(self):
62         themes = self.tags.filter(category='theme')
63         things = self.tags.filter(category='thing')
64         return {
65             'area': self,
66             'theme': themes[0] if themes else None,
67             'thing': things[0] if things else None,
68         }
69
70     def clear_cache(self):
71         clear_cached_renders(self.midi_box)
72
73
74 class Picture(models.Model):
75     """
76     Picture resource.
77
78     """
79     title = models.CharField(_('title'), max_length=32767)
80     slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
81     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
82     sort_key_author = models.CharField(
83         _('sort key by author'), max_length=120, db_index=True, editable=False, default='')
84     created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
85     changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
86     xml_file = models.FileField(_('xml file'), upload_to="xml", storage=picture_storage)
87     image_file = ImageField(_('image file'), upload_to="images", storage=picture_storage)
88     html_file = models.FileField(_('html file'), upload_to="html", storage=picture_storage)
89     areas_json = models.TextField(_('picture areas JSON'), default='{}', editable=False)
90     extra_info = models.TextField(_('extra information'), default='{}')
91     culturepl_link = models.CharField(blank=True, max_length=240)
92     wiki_link = models.CharField(blank=True, max_length=240)
93
94     width = models.IntegerField(null=True)
95     height = models.IntegerField(null=True)
96
97     objects = models.Manager()
98     tagged = managers.ModelTaggedItemManager(catalogue.models.Tag)
99     tags = managers.TagDescriptor(catalogue.models.Tag)
100     tag_relations = GenericRelation(catalogue.models.Tag.intermediary_table_model)
101
102     short_html_url_name = 'picture_short'
103
104     class AlreadyExists(Exception):
105         pass
106
107     class Meta:
108         ordering = ('sort_key_author', 'sort_key')
109
110         verbose_name = _('picture')
111         verbose_name_plural = _('pictures')
112
113     def save(self, force_insert=False, force_update=False, **kwargs):
114         from sortify import sortify
115
116         self.sort_key = sortify(self.title)[:120]
117
118         try:
119             author = self.authors().first().sort_key
120         except AttributeError:
121             author = ''
122         self.sort_key_author = author
123
124         ret = super(Picture, self).save(force_insert, force_update)
125
126         return ret
127
128     def __str__(self):
129         return self.title
130
131     def authors(self):
132         return self.tags.filter(category='author')
133
134     def tag_unicode(self, category):
135         relations = prefetched_relations(self, category)
136         if relations:
137             return ', '.join(rel.tag.name for rel in relations)
138         else:
139             return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
140
141     def author_unicode(self):
142         return self.tag_unicode('author')
143
144     def tags_by_category(self):
145         return split_tags(self.tags)
146
147     def get_absolute_url(self):
148         return reverse('picture_detail', args=[self.slug])
149
150     def get_initial(self):
151         try:
152             return re.search(r'\w', self.title, re.U).group(0)
153         except AttributeError:
154             return ''
155
156     def get_next(self):
157         try:
158             return type(self).objects.filter(sort_key__gt=self.sort_key)[0]
159         except IndexError:
160             return None
161
162     def get_previous(self):
163         try:
164             return type(self).objects.filter(sort_key__lt=self.sort_key).order_by('-sort_key')[0]
165         except IndexError:
166             return None
167
168     @classmethod
169     def from_xml_file(cls, xml_file, image_file=None, image_store=None, overwrite=False, search_index=True):
170         """
171         Import xml and it's accompanying image file.
172         If image file is missing, it will be fetched by librarian.picture.ImageStore
173         which looks for an image file in the same directory the xml is, with extension matching
174         its mime type.
175         """
176         from sortify import sortify
177         from django.core.files import File
178         from librarian.picture import WLPicture, ImageStore
179         close_xml_file = False
180         close_image_file = False
181
182         if image_file is not None and not isinstance(image_file, File):
183             image_file = File(open(image_file, 'rb'))
184             close_image_file = True
185
186         if not isinstance(xml_file, File):
187             xml_file = File(open(xml_file))
188             close_xml_file = True
189
190         with transaction.atomic():
191             # use librarian to parse meta-data
192             if image_store is None:
193                 image_store = ImageStore(picture_storage.path('images'))
194             picture_xml = WLPicture.from_file(xml_file, image_store=image_store)
195
196             picture, created = Picture.objects.get_or_create(slug=picture_xml.slug[:120])
197             if not created and not overwrite:
198                 raise Picture.AlreadyExists('Picture %s already exists' % picture_xml.slug)
199
200             picture.areas.all().delete()
201             picture.title = str(picture_xml.picture_info.title)
202             picture.extra_info = json.dumps(picture_xml.picture_info.to_dict())
203
204             picture_tags = set(catalogue.models.Tag.tags_from_info(picture_xml.picture_info))
205             for tag in picture_tags:
206                 if not tag.for_pictures:
207                     tag.for_pictures = True
208                     tag.save()
209
210             area_data = {'themes': {}, 'things': {}}
211
212             # Treat all names in picture XML as in default language.
213             lang = settings.LANGUAGE_CODE
214
215             for part in picture_xml.partiter():
216                 if picture_xml.frame:
217                     c = picture_xml.frame[0]
218                     part['coords'] = [[p[0] - c[0], p[1] - c[1]] for p in part['coords']]
219                 if part.get('object', None) is not None:
220                     _tags = set()
221                     for objname in part['object'].split(','):
222                         objname = objname.strip()
223                         assert objname, 'Empty object name'
224                         # str.capitalize() is wrong, because it also lowers letters
225                         objname = objname[0].upper() + objname[1:]
226                         tag, created = catalogue.models.Tag.objects.get_or_create(
227                             slug=slugify(objname), category='thing')
228                         if created:
229                             tag.name = objname
230                             setattr(tag, 'name_%s' % lang, tag.name)
231                             tag.sort_key = sortify(tag.name)
232                             tag.for_pictures = True
233                             tag.save()
234                         area_data['things'][tag.slug] = {
235                             'object': objname,
236                             'coords': part['coords'],
237                             }
238
239                         _tags.add(tag)
240                         if not tag.for_pictures:
241                             tag.for_pictures = True
242                             tag.save()
243                     area = PictureArea.rectangle(picture, 'thing', part['coords'])
244                     area.save()
245                     # WTF thing area does not inherit tags from picture and theme area does, is it intentional?
246                     area.tags = _tags
247                 else:
248                     _tags = set()
249                     for motifs in part['themes']:
250                         for motif in motifs.split(','):
251                             tag, created = catalogue.models.Tag.objects.get_or_create(
252                                 slug=slugify(motif), category='theme')
253                             if created:
254                                 tag.name = motif
255                                 tag.sort_key = sortify(tag.name)
256                                 tag.for_pictures = True
257                                 tag.save()
258                             # motif_tags.add(tag)
259                             _tags.add(tag)
260                             if not tag.for_pictures:
261                                 tag.for_pictures = True
262                                 tag.save()
263                             area_data['themes'][tag.slug] = {
264                                 'theme': motif,
265                                 'coords': part['coords']
266                                 }
267
268                     logging.debug("coords for theme: %s" % part['coords'])
269                     area = PictureArea.rectangle(picture, 'theme', part['coords'])
270                     area.save()
271                     area.tags = _tags.union(picture_tags)
272
273             picture.tags = picture_tags
274             picture.areas_json = json.dumps(area_data)
275
276             if image_file is not None:
277                 img = image_file
278             else:
279                 img = picture_xml.image_file()
280
281             modified = cls.crop_to_frame(picture_xml, img)
282             modified = cls.add_source_note(picture_xml, modified)
283
284             picture.width, picture.height = modified.size
285
286             modified_file = BytesIO()
287             modified.save(modified_file, format='JPEG', quality=95)
288             # FIXME: hardcoded extension - detect from DC format or orginal filename
289             picture.image_file.save(path.basename(picture_xml.image_path), File(modified_file))
290
291             picture.xml_file.save("%s.xml" % picture.slug, File(xml_file))
292             picture.save()
293             tasks.generate_picture_html(picture.id)
294             if not settings.NO_SEARCH_INDEX and search_index:
295                 tasks.index_picture.delay(picture.id, picture_info=picture_xml.picture_info)
296
297         if close_xml_file:
298             xml_file.close()
299         if close_image_file:
300             image_file.close()
301
302         return picture
303
304     @classmethod
305     def crop_to_frame(cls, wlpic, image_file):
306         img = Image.open(image_file)
307         if wlpic.frame is None or wlpic.frame == [[0, 0], [-1, -1]]:
308             return img
309         img = img.crop(itertools.chain(*wlpic.frame))
310         return img
311
312     @staticmethod
313     def add_source_note(wlpic, img):
314         from PIL import ImageDraw, ImageFont
315         from librarian import get_resource
316
317         annotated = Image.new(img.mode, (img.size[0], img.size[1] + 40), (255, 255, 255))
318         annotated.paste(img, (0, 0))
319         annotation = Image.new('RGB', (img.size[0] * 3, 120), (255, 255, 255))
320         ImageDraw.Draw(annotation).text(
321             (30, 15),
322             wlpic.picture_info.source_name,
323             (0, 0, 0),
324             font=ImageFont.truetype(get_resource("fonts/DejaVuSerif.ttf"), 75)
325         )
326         annotated.paste(annotation.resize((img.size[0], 40), Image.ANTIALIAS), (0, img.size[1]))
327         return annotated
328
329     @property
330     def info(self):
331         if not hasattr(self, '_info'):
332             from librarian import dcparser
333             from librarian import picture
334             info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
335             self._info = info
336         return self._info
337
338     def pretty_title(self, html_links=False):
339         names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
340         names.append((self.title, self.get_absolute_url()))
341
342         if html_links:
343             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
344         else:
345             names = [tag[0] for tag in names]
346         return ', '.join(names)
347
348     @cached_render('picture/picture_mini_box.html')
349     def mini_box(self):
350         return {
351             'picture': self,
352         }
353
354     @cached_render('picture/picture_short.html')
355     def midi_box(self):
356         return {
357             'picture': self,
358         }
359
360     def related_themes(self):
361         return catalogue.models.Tag.objects.usage_for_queryset(
362             self.areas.all(), counts=True).filter(category__in=('theme', 'thing'))
363
364     def clear_cache(self):
365         clear_cached_renders(self.mini_box)
366         clear_cached_renders(self.midi_box)
367
368     def search_index(self, picture_info=None, index=None, index_tags=True, commit=True):
369         if index is None:
370             from search.index import Index
371             index = Index()
372         try:
373             index.index_picture(self, picture_info)
374             if index_tags:
375                 index.index_tags()
376             if commit:
377                 index.index.commit()
378         except Exception as e:
379             index.index.rollback()
380             raise e