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