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