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