Uncrazy the caching, part 1.
[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 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     def flush_includes(self, languages=True):
58         if not languages:
59             return
60         if languages is True:
61             languages = [lc for (lc, _ln) in settings.LANGUAGES]
62         flush_ssi_includes([
63             template % (self.pk, lang)
64             for template in [
65                 '/katalog/pa/%d/short.%s.html',
66                 ]
67             for lang in languages
68             ])
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     def related_themes(self):
346         return catalogue.models.Tag.objects.usage_for_queryset(
347             self.areas.all(), counts=True).filter(category__in=('theme', 'thing'))
348
349     def flush_includes(self, languages=True):
350         if not languages:
351             return
352         if languages is True:
353             languages = [lc for (lc, _ln) in settings.LANGUAGES]
354         flush_ssi_includes([
355             template % (self.pk, lang)
356             for template in [
357                 '/katalog/p/%d/short.%s.html',
358                 '/katalog/p/%d/mini.%s.html',
359                 ]
360             for lang in languages
361             ])
362
363     def search_index(self, picture_info=None, index=None, index_tags=True, commit=True):
364         if index is None:
365             from search.index import Index
366             index = Index()
367         try:
368             index.index_picture(self, picture_info)
369             if index_tags:
370                 index.index_tags()
371             if commit:
372                 index.index.commit()
373         except Exception as e:
374             index.index.rollback()
375             raise e