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