Add missing constraint.
[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, search_index=True):
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             if not settings.NO_SEARCH_INDEX and search_index:
309                 tasks.index_picture.delay(picture.id, picture_info=picture_xml.picture_info)
310
311         if close_xml_file:
312             xml_file.close()
313         if close_image_file:
314             image_file.close()
315
316         return picture
317
318     @classmethod
319     def crop_to_frame(cls, wlpic, image_file):
320         img = Image.open(image_file)
321         if wlpic.frame is None or wlpic.frame == [[0, 0], [-1, -1]]:
322             return img
323         img = img.crop(itertools.chain(*wlpic.frame))
324         return img
325
326     @staticmethod
327     def add_source_note(wlpic, img):
328         from PIL import ImageDraw, ImageFont
329         from librarian import get_resource
330
331         annotated = Image.new(img.mode, (img.size[0], img.size[1] + 40), (255, 255, 255))
332         annotated.paste(img, (0, 0))
333         annotation = Image.new('RGB', (img.size[0] * 3, 120), (255, 255, 255))
334         ImageDraw.Draw(annotation).text(
335             (30, 15),
336             wlpic.picture_info.source_name,
337             (0, 0, 0),
338             font=ImageFont.truetype(get_resource("fonts/DejaVuSerif.ttf"), 75)
339         )
340         annotated.paste(annotation.resize((img.size[0], 40), Image.ANTIALIAS), (0, img.size[1]))
341         return annotated
342
343     @property
344     def info(self):
345         if not hasattr(self, '_info'):
346             from librarian import dcparser
347             from librarian import picture
348             info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
349             self._info = info
350         return self._info
351
352     def pretty_title(self, html_links=False):
353         names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
354         names.append((self.title, self.get_absolute_url()))
355
356         if html_links:
357             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
358         else:
359             names = [tag[0] for tag in names]
360         return ', '.join(names)
361
362     @cached_render('picture/picture_mini_box.html')
363     def mini_box(self):
364         return {
365             'picture': self,
366         }
367
368     @cached_render('picture/picture_short.html')
369     def midi_box(self):
370         return {
371             'picture': self,
372         }
373
374     def related_themes(self):
375         return catalogue.models.Tag.objects.usage_for_queryset(
376             self.areas.all(), counts=True).filter(category__in=('theme', 'thing'))
377
378     def clear_cache(self):
379         clear_cached_renders(self.mini_box)
380         clear_cached_renders(self.midi_box)
381
382     def search_index(self, picture_info=None, index=None, index_tags=True, commit=True):
383         if index is None:
384             from search.index import Index
385             index = Index()
386         try:
387             index.index_picture(self, picture_info)
388             if index_tags:
389                 index.index_tags()
390             if commit:
391                 index.index.commit()
392         except Exception as e:
393             index.index.rollback()
394             raise e