Fix downloading images on publish.
[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 get_extra_info_json(self):
114         return json.loads(self.extra_info or '{}')
115
116     def save(self, force_insert=False, force_update=False, **kwargs):
117         from sortify import sortify
118
119         self.sort_key = sortify(self.title)[:120]
120
121         try:
122             author = self.authors().first().sort_key
123         except AttributeError:
124             author = ''
125         self.sort_key_author = author
126
127         ret = super(Picture, self).save(force_insert, force_update)
128
129         return ret
130
131     def __str__(self):
132         return self.title
133
134     def authors(self):
135         return self.tags.filter(category='author')
136
137     def tag_unicode(self, category):
138         relations = prefetched_relations(self, category)
139         if relations:
140             return ', '.join(rel.tag.name for rel in relations)
141         else:
142             return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
143
144     def author_unicode(self):
145         return self.tag_unicode('author')
146
147     def tags_by_category(self):
148         return split_tags(self.tags)
149
150     def get_absolute_url(self):
151         return reverse('picture_detail', args=[self.slug])
152
153     def get_initial(self):
154         try:
155             return re.search(r'\w', self.title, re.U).group(0)
156         except AttributeError:
157             return ''
158
159     def get_next(self):
160         try:
161             return type(self).objects.filter(sort_key__gt=self.sort_key)[0]
162         except IndexError:
163             return None
164
165     def get_previous(self):
166         try:
167             return type(self).objects.filter(sort_key__lt=self.sort_key).order_by('-sort_key')[0]
168         except IndexError:
169             return None
170
171     @classmethod
172     def from_xml_file(cls, xml_file, image_file=None, image_store=None, overwrite=False, search_index=True):
173         """
174         Import xml and it's accompanying image file.
175         If image file is missing, it will be fetched by librarian.picture.ImageStore
176         which looks for an image file in the same directory the xml is, with extension matching
177         its mime type.
178         """
179         from sortify import sortify
180         from django.core.files import File
181         from librarian.picture import WLPicture, ImageStore
182         close_xml_file = False
183         close_image_file = False
184
185         if image_file is not None and not isinstance(image_file, File):
186             image_file = File(open(image_file, 'rb'))
187             close_image_file = True
188
189         if not isinstance(xml_file, File):
190             xml_file = File(open(xml_file))
191             close_xml_file = True
192
193         with transaction.atomic():
194             # use librarian to parse meta-data
195             if image_store is None:
196                 image_store = ImageStore(picture_storage.path('images'))
197             picture_xml = WLPicture.from_file(xml_file, image_store=image_store)
198
199             picture, created = Picture.objects.get_or_create(slug=picture_xml.slug[:120])
200             if not created and not overwrite:
201                 raise Picture.AlreadyExists('Picture %s already exists' % picture_xml.slug)
202
203             picture.areas.all().delete()
204             picture.title = str(picture_xml.picture_info.title)
205             picture.extra_info = json.dumps(picture_xml.picture_info.to_dict())
206
207             picture_tags = set(catalogue.models.Tag.tags_from_info(picture_xml.picture_info))
208             for tag in picture_tags:
209                 if not tag.for_pictures:
210                     tag.for_pictures = True
211                     tag.save()
212
213             area_data = {'themes': {}, 'things': {}}
214
215             # Treat all names in picture XML as in default language.
216             lang = settings.LANGUAGE_CODE
217
218             for part in picture_xml.partiter():
219                 if picture_xml.frame:
220                     c = picture_xml.frame[0]
221                     part['coords'] = [[p[0] - c[0], p[1] - c[1]] for p in part['coords']]
222                 if part.get('object', None) is not None:
223                     _tags = set()
224                     for objname in part['object'].split(','):
225                         objname = objname.strip()
226                         assert objname, 'Empty object name'
227                         # str.capitalize() is wrong, because it also lowers letters
228                         objname = objname[0].upper() + objname[1:]
229                         tag, created = catalogue.models.Tag.objects.get_or_create(
230                             slug=slugify(objname), category='thing')
231                         if created:
232                             tag.name = objname
233                             setattr(tag, 'name_%s' % lang, tag.name)
234                             tag.sort_key = sortify(tag.name)
235                             tag.for_pictures = True
236                             tag.save()
237                         area_data['things'][tag.slug] = {
238                             'object': objname,
239                             'coords': part['coords'],
240                             }
241
242                         _tags.add(tag)
243                         if not tag.for_pictures:
244                             tag.for_pictures = True
245                             tag.save()
246                     area = PictureArea.rectangle(picture, 'thing', part['coords'])
247                     area.save()
248                     # WTF thing area does not inherit tags from picture and theme area does, is it intentional?
249                     area.tags = _tags
250                 else:
251                     _tags = set()
252                     for motifs in part['themes']:
253                         for motif in motifs.split(','):
254                             tag, created = catalogue.models.Tag.objects.get_or_create(
255                                 slug=slugify(motif), category='theme')
256                             if created:
257                                 tag.name = motif
258                                 tag.sort_key = sortify(tag.name)
259                                 tag.for_pictures = True
260                                 tag.save()
261                             # motif_tags.add(tag)
262                             _tags.add(tag)
263                             if not tag.for_pictures:
264                                 tag.for_pictures = True
265                                 tag.save()
266                             area_data['themes'][tag.slug] = {
267                                 'theme': motif,
268                                 'coords': part['coords']
269                                 }
270
271                     logging.debug("coords for theme: %s" % part['coords'])
272                     area = PictureArea.rectangle(picture, 'theme', part['coords'])
273                     area.save()
274                     area.tags = _tags.union(picture_tags)
275
276             picture.tags = picture_tags
277             picture.areas_json = json.dumps(area_data)
278
279             if image_file is not None:
280                 img = image_file
281             else:
282                 img = picture_xml.image_file()
283
284             modified = cls.crop_to_frame(picture_xml, img)
285             modified = cls.add_source_note(picture_xml, modified)
286
287             picture.width, picture.height = modified.size
288
289             modified_file = BytesIO()
290             modified.save(modified_file, format='JPEG', quality=95)
291             # FIXME: hardcoded extension - detect from DC format or orginal filename
292             picture.image_file.save(path.basename(picture_xml.image_path), File(modified_file))
293
294             picture.xml_file.save("%s.xml" % picture.slug, File(xml_file))
295             picture.save()
296             tasks.generate_picture_html(picture.id)
297             if not settings.NO_SEARCH_INDEX and search_index:
298                 tasks.index_picture.delay(picture.id, picture_info=picture_xml.picture_info)
299
300         if close_xml_file:
301             xml_file.close()
302         if close_image_file:
303             image_file.close()
304
305         return picture
306
307     @classmethod
308     def crop_to_frame(cls, wlpic, image_file):
309         img = Image.open(image_file)
310         if wlpic.frame is None or wlpic.frame == [[0, 0], [-1, -1]]:
311             return img
312         img = img.crop(itertools.chain(*wlpic.frame))
313         return img
314
315     @staticmethod
316     def add_source_note(wlpic, img):
317         from PIL import ImageDraw, ImageFont
318         from librarian import get_resource
319
320         annotated = Image.new(img.mode, (img.size[0], img.size[1] + 40), (255, 255, 255))
321         annotated.paste(img, (0, 0))
322         annotation = Image.new('RGB', (img.size[0] * 3, 120), (255, 255, 255))
323         ImageDraw.Draw(annotation).text(
324             (30, 15),
325             wlpic.picture_info.source_name,
326             (0, 0, 0),
327             font=ImageFont.truetype(get_resource("fonts/DejaVuSerif.ttf"), 75)
328         )
329         annotated.paste(annotation.resize((img.size[0], 40), Image.ANTIALIAS), (0, img.size[1]))
330         return annotated
331
332     @property
333     def info(self):
334         if not hasattr(self, '_info'):
335             from librarian import dcparser
336             from librarian import picture
337             info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
338             self._info = info
339         return self._info
340
341     def pretty_title(self, html_links=False):
342         names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
343         names.append((self.title, self.get_absolute_url()))
344
345         if html_links:
346             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
347         else:
348             names = [tag[0] for tag in names]
349         return ', '.join(names)
350
351     @cached_render('picture/picture_mini_box.html')
352     def mini_box(self):
353         return {
354             'picture': self,
355         }
356
357     @cached_render('picture/picture_short.html')
358     def midi_box(self):
359         return {
360             'picture': self,
361         }
362
363     def related_themes(self):
364         return catalogue.models.Tag.objects.usage_for_queryset(
365             self.areas.all(), counts=True).filter(category__in=('theme', 'thing'))
366
367     def clear_cache(self):
368         clear_cached_renders(self.mini_box)
369         clear_cached_renders(self.midi_box)
370
371     def search_index(self, picture_info=None, index=None, index_tags=True, commit=True):
372         if index is None:
373             from search.index import Index
374             index = Index()
375         try:
376             index.index_picture(self, picture_info)
377             if index_tags:
378                 index.index_tags()
379             if commit:
380                 index.index.commit()
381         except Exception as e:
382             index.index.rollback()
383             raise e