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