Str/bytes fix for publishing audiobooks.
[wolnelektury.git] / src / picture / models.py
1 # -*- coding: utf-8 -*-
2 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
3 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
4 #
5 from django.db import models, transaction
6 import catalogue.models
7 from django.db.models import permalink
8 from sorl.thumbnail import ImageField
9 from django.conf import settings
10 from django.contrib.contenttypes.fields import GenericRelation
11 from django.core.files.storage import FileSystemStorage
12 from slugify import slugify
13 from ssify import flush_ssi_includes
14
15 from catalogue.models.tag import prefetched_relations
16 from catalogue.utils import split_tags
17 from picture import tasks
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', 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     @permalink
146     def get_absolute_url(self):
147         return 'picture_detail', [self.slug]
148
149     def get_initial(self):
150         try:
151             return re.search(r'\w', self.title, re.U).group(0)
152         except AttributeError:
153             return ''
154
155     def get_next(self):
156         try:
157             return type(self).objects.filter(sort_key__gt=self.sort_key)[0]
158         except IndexError:
159             return None
160
161     def get_previous(self):
162         try:
163             return type(self).objects.filter(sort_key__lt=self.sort_key).order_by('-sort_key')[0]
164         except IndexError:
165             return None
166
167     @classmethod
168     def from_xml_file(cls, xml_file, image_file=None, image_store=None, overwrite=False, search_index=True):
169         """
170         Import xml and it's accompanying image file.
171         If image file is missing, it will be fetched by librarian.picture.ImageStore
172         which looks for an image file in the same directory the xml is, with extension matching
173         its mime type.
174         """
175         from sortify import sortify
176         from django.core.files import File
177         from librarian.picture import WLPicture, ImageStore
178         close_xml_file = False
179         close_image_file = False
180
181         if image_file is not None and not isinstance(image_file, File):
182             image_file = File(open(image_file, 'rb'))
183             close_image_file = True
184
185         if not isinstance(xml_file, File):
186             xml_file = File(open(xml_file))
187             close_xml_file = True
188
189         with transaction.atomic():
190             # use librarian to parse meta-data
191             if image_store is None:
192                 image_store = ImageStore(picture_storage.path('images'))
193             picture_xml = WLPicture.from_file(xml_file, image_store=image_store)
194
195             picture, created = Picture.objects.get_or_create(slug=picture_xml.slug[:120])
196             if not created and not overwrite:
197                 raise Picture.AlreadyExists('Picture %s already exists' % picture_xml.slug)
198
199             picture.areas.all().delete()
200             picture.title = str(picture_xml.picture_info.title)
201             picture.extra_info = picture_xml.picture_info.to_dict()
202
203             picture_tags = set(catalogue.models.Tag.tags_from_info(picture_xml.picture_info))
204             for tag in picture_tags:
205                 if not tag.for_pictures:
206                     tag.for_pictures = True
207                     tag.save()
208
209             area_data = {'themes': {}, 'things': {}}
210
211             # Treat all names in picture XML as in default language.
212             lang = settings.LANGUAGE_CODE
213
214             for part in picture_xml.partiter():
215                 if picture_xml.frame:
216                     c = picture_xml.frame[0]
217                     part['coords'] = [[p[0] - c[0], p[1] - c[1]] for p in part['coords']]
218                 if part.get('object', None) is not None:
219                     _tags = set()
220                     for objname in part['object'].split(','):
221                         objname = objname.strip()
222                         assert objname, 'Empty object name'
223                         # str.capitalize() is wrong, because it also lowers letters
224                         objname = objname[0].upper() + objname[1:]
225                         tag, created = catalogue.models.Tag.objects.get_or_create(
226                             slug=slugify(objname), category='thing')
227                         if created:
228                             tag.name = objname
229                             setattr(tag, 'name_%s' % lang, tag.name)
230                             tag.sort_key = sortify(tag.name)
231                             tag.for_pictures = True
232                             tag.save()
233                         area_data['things'][tag.slug] = {
234                             'object': objname,
235                             'coords': part['coords'],
236                             }
237
238                         _tags.add(tag)
239                         if not tag.for_pictures:
240                             tag.for_pictures = True
241                             tag.save()
242                     area = PictureArea.rectangle(picture, 'thing', part['coords'])
243                     area.save()
244                     # WTF thing area does not inherit tags from picture and theme area does, is it intentional?
245                     area.tags = _tags
246                 else:
247                     _tags = set()
248                     for motifs in part['themes']:
249                         for motif in motifs.split(','):
250                             tag, created = catalogue.models.Tag.objects.get_or_create(
251                                 slug=slugify(motif), category='theme')
252                             if created:
253                                 tag.name = motif
254                                 tag.sort_key = sortify(tag.name)
255                                 tag.for_pictures = True
256                                 tag.save()
257                             # motif_tags.add(tag)
258                             _tags.add(tag)
259                             if not tag.for_pictures:
260                                 tag.for_pictures = True
261                                 tag.save()
262                             area_data['themes'][tag.slug] = {
263                                 'theme': motif,
264                                 'coords': part['coords']
265                                 }
266
267                     logging.debug("coords for theme: %s" % part['coords'])
268                     area = PictureArea.rectangle(picture, 'theme', part['coords'])
269                     area.save()
270                     area.tags = _tags.union(picture_tags)
271
272             picture.tags = picture_tags
273             picture.areas_json = area_data
274
275             if image_file is not None:
276                 img = image_file
277             else:
278                 img = picture_xml.image_file()
279
280             modified = cls.crop_to_frame(picture_xml, img)
281             modified = cls.add_source_note(picture_xml, modified)
282
283             picture.width, picture.height = modified.size
284
285             modified_file = BytesIO()
286             modified.save(modified_file, format='JPEG', quality=95)
287             # FIXME: hardcoded extension - detect from DC format or orginal filename
288             picture.image_file.save(path.basename(picture_xml.image_path), File(modified_file))
289
290             picture.xml_file.save("%s.xml" % picture.slug, File(xml_file))
291             picture.save()
292             tasks.generate_picture_html(picture.id)
293             if not settings.NO_SEARCH_INDEX and search_index:
294                 tasks.index_picture.delay(picture.id, picture_info=picture_xml.picture_info)
295
296         if close_xml_file:
297             xml_file.close()
298         if close_image_file:
299             image_file.close()
300
301         return picture
302
303     @classmethod
304     def crop_to_frame(cls, wlpic, image_file):
305         img = Image.open(image_file)
306         if wlpic.frame is None or wlpic.frame == [[0, 0], [-1, -1]]:
307             return img
308         img = img.crop(itertools.chain(*wlpic.frame))
309         return img
310
311     @staticmethod
312     def add_source_note(wlpic, img):
313         from PIL import ImageDraw, ImageFont
314         from librarian import get_resource
315
316         annotated = Image.new(img.mode, (img.size[0], img.size[1] + 40), (255, 255, 255))
317         annotated.paste(img, (0, 0))
318         annotation = Image.new('RGB', (img.size[0] * 3, 120), (255, 255, 255))
319         ImageDraw.Draw(annotation).text(
320             (30, 15),
321             wlpic.picture_info.source_name,
322             (0, 0, 0),
323             font=ImageFont.truetype(get_resource("fonts/DejaVuSerif.ttf"), 75)
324         )
325         annotated.paste(annotation.resize((img.size[0], 40), Image.ANTIALIAS), (0, img.size[1]))
326         return annotated
327
328     @property
329     def info(self):
330         if not hasattr(self, '_info'):
331             from librarian import dcparser
332             from librarian import picture
333             info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
334             self._info = info
335         return self._info
336
337     def pretty_title(self, html_links=False):
338         names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
339         names.append((self.title, self.get_absolute_url()))
340
341         if html_links:
342             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
343         else:
344             names = [tag[0] for tag in names]
345         return ', '.join(names)
346
347     def related_themes(self):
348         return catalogue.models.Tag.objects.usage_for_queryset(
349             self.areas.all(), counts=True).filter(category__in=('theme', 'thing'))
350
351     def flush_includes(self, languages=True):
352         if not languages:
353             return
354         if languages is True:
355             languages = [lc for (lc, _ln) in settings.LANGUAGES]
356         flush_ssi_includes([
357             template % (self.pk, lang)
358             for template in [
359                 '/katalog/p/%d/short.%s.html',
360                 '/katalog/p/%d/mini.%s.html',
361                 ]
362             for lang in languages
363             ])
364
365     def search_index(self, picture_info=None, index=None, index_tags=True, commit=True):
366         if index is None:
367             from search.index import Index
368             index = Index()
369         try:
370             index.index_picture(self, picture_info)
371             if index_tags:
372                 index.index_tags()
373             if commit:
374                 index.index.commit()
375         except Exception as e:
376             index.index.rollback()
377             raise e