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