optimize db usage in tagged object list
[wolnelektury.git] / src / picture / models.py
1 # -*- coding: utf-8 -*-
2 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
3 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
4 #
5 from django.db import models, transaction
6 import catalogue.models
7 from django.db.models import permalink
8 from sorl.thumbnail import ImageField
9 from django.conf import settings
10 from django.contrib.contenttypes.fields import GenericRelation
11 from django.core.files.storage import FileSystemStorage
12 from django.utils.datastructures import SortedDict
13 from fnpdjango.utils.text.slughifi import slughifi
14 from ssify import flush_ssi_includes
15
16 from catalogue.models.tag import prefetched_relations
17 from picture import tasks
18 from StringIO import StringIO
19 import jsonfield
20 import itertools
21 import logging
22 import re
23
24 from PIL import Image
25
26 from django.utils.translation import ugettext_lazy as _
27 from newtagging import managers
28 from os import path
29
30
31 picture_storage = FileSystemStorage(location=path.join(
32         settings.MEDIA_ROOT, 'pictures'),
33         base_url=settings.MEDIA_URL + "pictures/")
34
35
36 class PictureArea(models.Model):
37     picture = models.ForeignKey('picture.Picture', related_name='areas')
38     area = jsonfield.JSONField(_('area'), default={}, editable=False)
39     kind = models.CharField(
40         _('kind'), max_length=10, blank=False, null=False, db_index=True,
41         choices=(('thing', _('thing')), ('theme', _('theme'))))
42
43     objects = models.Manager()
44     tagged = managers.ModelTaggedItemManager(catalogue.models.Tag)
45     tags = managers.TagDescriptor(catalogue.models.Tag)
46     tag_relations = GenericRelation(catalogue.models.Tag.intermediary_table_model)
47
48     short_html_url_name = 'picture_area_short'
49
50     @classmethod
51     def rectangle(cls, picture, kind, coords):
52         pa = PictureArea()
53         pa.picture = picture
54         pa.kind = kind
55         pa.area = coords
56         return pa
57
58     def flush_includes(self, languages=True):
59         if not languages:
60             return
61         if languages is True:
62             languages = [lc for (lc, _ln) in settings.LANGUAGES]
63         flush_ssi_includes([
64             template % (self.pk, lang)
65             for template in [
66                 '/katalog/pa/%d/short.%s.html',
67                 ]
68             for lang in languages
69             ])
70
71
72 class Picture(models.Model):
73     """
74     Picture resource.
75
76     """
77     title = models.CharField(_('title'), max_length=32767)
78     slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
79     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
80     sort_key_author = models.CharField(
81         _('sort key by author'), max_length=120, db_index=True, editable=False, default=u'')
82     created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
83     changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
84     xml_file = models.FileField(_('xml file'), upload_to="xml", storage=picture_storage)
85     image_file = ImageField(_('image file'), upload_to="images", storage=picture_storage)
86     html_file = models.FileField(_('html file'), upload_to="html", storage=picture_storage)
87     areas_json = jsonfield.JSONField(_('picture areas JSON'), default={}, editable=False)
88     extra_info = jsonfield.JSONField(_('extra information'), default={})
89     culturepl_link = models.CharField(blank=True, max_length=240)
90     wiki_link = models.CharField(blank=True, max_length=240)
91
92     width = models.IntegerField(null=True)
93     height = models.IntegerField(null=True)
94
95     objects = models.Manager()
96     tagged = managers.ModelTaggedItemManager(catalogue.models.Tag)
97     tags = managers.TagDescriptor(catalogue.models.Tag)
98     tag_relations = GenericRelation(catalogue.models.Tag.intermediary_table_model)
99
100     short_html_url_name = 'picture_short'
101
102     class AlreadyExists(Exception):
103         pass
104
105     class Meta:
106         ordering = ('sort_key_author', 'sort_key')
107
108         verbose_name = _('picture')
109         verbose_name_plural = _('pictures')
110
111     def save(self, force_insert=False, force_update=False, **kwargs):
112         from sortify import sortify
113
114         self.sort_key = sortify(self.title)[:120]
115
116         try:
117             author = self.authors().first().sort_key
118         except AttributeError:
119             author = u''
120         self.sort_key_author = author
121
122         ret = super(Picture, self).save(force_insert, force_update)
123
124         return ret
125
126     def __unicode__(self):
127         return self.title
128
129     def authors(self):
130         return self.tags.filter(category='author')
131
132     def tag_unicode(self, category):
133         relations = prefetched_relations(self, category)
134         if relations:
135             return ', '.join(rel.tag.name for rel in relations)
136         else:
137             return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
138
139     def author_unicode(self):
140         return self.tag_unicode('author')
141
142     @permalink
143     def get_absolute_url(self):
144         return 'picture.views.picture_detail', [self.slug]
145
146     def get_initial(self):
147         try:
148             return re.search(r'\w', self.title, re.U).group(0)
149         except AttributeError:
150             return ''
151
152     def get_next(self):
153         try:
154             return type(self).objects.filter(sort_key__gt=self.sort_key)[0]
155         except IndexError:
156             return None
157
158     def get_previous(self):
159         try:
160             return type(self).objects.filter(sort_key__lt=self.sort_key).order_by('-sort_key')[0]
161         except IndexError:
162             return None
163
164     @classmethod
165     def from_xml_file(cls, xml_file, image_file=None, image_store=None, overwrite=False):
166         """
167         Import xml and it's accompanying image file.
168         If image file is missing, it will be fetched by librarian.picture.ImageStore
169         which looks for an image file in the same directory the xml is, with extension matching
170         its mime type.
171         """
172         from sortify import sortify
173         from django.core.files import File
174         from librarian.picture import WLPicture, ImageStore
175         close_xml_file = False
176         close_image_file = False
177
178         if image_file is not None and not isinstance(image_file, File):
179             image_file = File(open(image_file))
180             close_image_file = True
181
182         if not isinstance(xml_file, File):
183             xml_file = File(open(xml_file))
184             close_xml_file = True
185
186         with transaction.atomic():
187             # use librarian to parse meta-data
188             if image_store is None:
189                 image_store = ImageStore(picture_storage.path('images'))
190             picture_xml = WLPicture.from_file(xml_file, image_store=image_store)
191
192             picture, created = Picture.objects.get_or_create(slug=picture_xml.slug[:120])
193             if not created and not overwrite:
194                 raise Picture.AlreadyExists('Picture %s already exists' % picture_xml.slug)
195
196             picture.areas.all().delete()
197             picture.title = unicode(picture_xml.picture_info.title)
198             picture.extra_info = picture_xml.picture_info.to_dict()
199
200             picture_tags = set(catalogue.models.Tag.tags_from_info(picture_xml.picture_info))
201             motif_tags = set()
202             thing_tags = set()
203
204             area_data = {'themes': {}, 'things': {}}
205
206             # Treat all names in picture XML as in default language.
207             lang = settings.LANGUAGE_CODE
208
209             for part in picture_xml.partiter():
210                 if picture_xml.frame:
211                     c = picture_xml.frame[0]
212                     part['coords'] = [[p[0] - c[0], p[1] - c[1]] for p in part['coords']]
213                 if part.get('object', None) is not None:
214                     _tags = set()
215                     for objname in part['object'].split(','):
216                         objname = objname.strip().capitalize()
217                         tag, created = catalogue.models.Tag.objects.get_or_create(
218                             slug=slughifi(objname), category='thing')
219                         if created:
220                             tag.name = objname
221                             setattr(tag, 'name_%s' % lang, tag.name)
222                             tag.sort_key = sortify(tag.name)
223                             tag.save()
224                         # thing_tags.add(tag)
225                         area_data['things'][tag.slug] = {
226                             'object': objname,
227                             'coords': part['coords'],
228                             }
229
230                         _tags.add(tag)
231                     area = PictureArea.rectangle(picture, 'thing', part['coords'])
232                     area.save()
233                     area.tags = _tags
234                 else:
235                     _tags = set()
236                     for motifs in part['themes']:
237                         for motif in motifs.split(','):
238                             tag, created = catalogue.models.Tag.objects.get_or_create(
239                                 slug=slughifi(motif), category='theme')
240                             if created:
241                                 tag.name = motif
242                                 tag.sort_key = sortify(tag.name)
243                                 tag.save()
244                             # motif_tags.add(tag)
245                             _tags.add(tag)
246                             area_data['themes'][tag.slug] = {
247                                 'theme': motif,
248                                 'coords': part['coords']
249                                 }
250
251                     logging.debug("coords for theme: %s" % part['coords'])
252                     area = PictureArea.rectangle(picture, 'theme', part['coords'])
253                     area.save()
254                     area.tags = _tags.union(picture_tags)
255
256             picture.tags = picture_tags.union(motif_tags).union(thing_tags)
257             picture.areas_json = area_data
258
259             if image_file is not None:
260                 img = image_file
261             else:
262                 img = picture_xml.image_file()
263
264             modified = cls.crop_to_frame(picture_xml, img)
265             modified = cls.add_source_note(picture_xml, modified)
266
267             picture.width, picture.height = modified.size
268
269             modified_file = StringIO()
270             modified.save(modified_file, format='JPEG', quality=95)
271             # FIXME: hardcoded extension - detect from DC format or orginal filename
272             picture.image_file.save(path.basename(picture_xml.image_path), File(modified_file))
273
274             picture.xml_file.save("%s.xml" % picture.slug, File(xml_file))
275             picture.save()
276             tasks.generate_picture_html(picture.id)
277
278         if close_xml_file:
279             xml_file.close()
280         if close_image_file:
281             image_file.close()
282
283         return picture
284
285     @classmethod
286     def crop_to_frame(cls, wlpic, image_file):
287         img = Image.open(image_file)
288         if wlpic.frame is None or wlpic.frame == [[0, 0], [-1, -1]]:
289             return img
290         img = img.crop(itertools.chain(*wlpic.frame))
291         return img
292
293     @staticmethod
294     def add_source_note(wlpic, img):
295         from PIL import ImageDraw, ImageFont
296         from librarian import get_resource
297
298         annotated = Image.new(img.mode, (img.size[0], img.size[1] + 40), (255, 255, 255))
299         annotated.paste(img, (0, 0))
300         annotation = Image.new('RGB', (img.size[0] * 3, 120), (255, 255, 255))
301         ImageDraw.Draw(annotation).text(
302             (30, 15),
303             wlpic.picture_info.source_name,
304             (0, 0, 0),
305             font=ImageFont.truetype(get_resource("fonts/DejaVuSerif.ttf"), 75)
306         )
307         annotated.paste(annotation.resize((img.size[0], 40), Image.ANTIALIAS), (0, img.size[1]))
308         return annotated
309
310     # WTF/unused
311     @classmethod
312     def picture_list(cls, filter=None):
313         """Generates a hierarchical listing of all pictures
314         Pictures are optionally filtered with a test function.
315         """
316
317         pics = cls.objects.all().order_by('sort_key').only('title', 'slug', 'image_file')
318
319         if filter:
320             pics = pics.filter(filter).distinct()
321
322         pics_by_author = SortedDict()
323         orphans = []
324         for tag in catalogue.models.Tag.objects.filter(category='author'):
325             pics_by_author[tag] = []
326
327         for pic in pics.iterator():
328             authors = list(pic.authors().only('pk'))
329             if authors:
330                 for author in authors:
331                     pics_by_author[author].append(pic)
332             else:
333                 orphans.append(pic)
334
335         return pics_by_author, orphans
336
337     @property
338     def info(self):
339         if not hasattr(self, '_info'):
340             from librarian import dcparser
341             from librarian import picture
342             info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
343             self._info = info
344         return self._info
345
346     def pretty_title(self, html_links=False):
347         names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
348         names.append((self.title, self.get_absolute_url()))
349
350         if html_links:
351             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
352         else:
353             names = [tag[0] for tag in names]
354         return ', '.join(names)
355
356     def related_themes(self):
357         return catalogue.models.Tag.objects.usage_for_queryset(
358             self.areas.all(), counts=True).filter(category__in=('theme', 'thing'))
359
360     def flush_includes(self, languages=True):
361         if not languages:
362             return
363         if languages is True:
364             languages = [lc for (lc, _ln) in settings.LANGUAGES]
365         flush_ssi_includes([
366             template % (self.pk, lang)
367             for template in [
368                 '/katalog/p/%d/short.%s.html',
369                 '/katalog/p/%d/mini.%s.html',
370                 ]
371             for lang in languages
372             ])